videowright 0.1.0

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 (306) hide show
  1. package/README.md +91 -0
  2. package/dist/cli/argv.d.ts +28 -0
  3. package/dist/cli/argv.d.ts.map +1 -0
  4. package/dist/cli/argv.js +115 -0
  5. package/dist/cli/argv.js.map +1 -0
  6. package/dist/cli/bin.d.ts +7 -0
  7. package/dist/cli/bin.d.ts.map +1 -0
  8. package/dist/cli/bin.js +10 -0
  9. package/dist/cli/bin.js.map +1 -0
  10. package/dist/cli/dev.d.ts +19 -0
  11. package/dist/cli/dev.d.ts.map +1 -0
  12. package/dist/cli/dev.js +104 -0
  13. package/dist/cli/dev.js.map +1 -0
  14. package/dist/cli/discover.d.ts +29 -0
  15. package/dist/cli/discover.d.ts.map +1 -0
  16. package/dist/cli/discover.js +104 -0
  17. package/dist/cli/discover.js.map +1 -0
  18. package/dist/cli/discover_project.d.ts +29 -0
  19. package/dist/cli/discover_project.d.ts.map +1 -0
  20. package/dist/cli/discover_project.js +108 -0
  21. package/dist/cli/discover_project.js.map +1 -0
  22. package/dist/cli/errors.d.ts +10 -0
  23. package/dist/cli/errors.d.ts.map +1 -0
  24. package/dist/cli/errors.js +13 -0
  25. package/dist/cli/errors.js.map +1 -0
  26. package/dist/cli/ffmpeg.d.ts +57 -0
  27. package/dist/cli/ffmpeg.d.ts.map +1 -0
  28. package/dist/cli/ffmpeg.js +122 -0
  29. package/dist/cli/ffmpeg.js.map +1 -0
  30. package/dist/cli/index.d.ts +7 -0
  31. package/dist/cli/index.d.ts.map +1 -0
  32. package/dist/cli/index.js +152 -0
  33. package/dist/cli/index.js.map +1 -0
  34. package/dist/cli/playwright_check.d.ts +44 -0
  35. package/dist/cli/playwright_check.d.ts.map +1 -0
  36. package/dist/cli/playwright_check.js +20 -0
  37. package/dist/cli/playwright_check.js.map +1 -0
  38. package/dist/cli/prompt.d.ts +13 -0
  39. package/dist/cli/prompt.d.ts.map +1 -0
  40. package/dist/cli/prompt.js +47 -0
  41. package/dist/cli/prompt.js.map +1 -0
  42. package/dist/cli/render.d.ts +60 -0
  43. package/dist/cli/render.d.ts.map +1 -0
  44. package/dist/cli/render.js +471 -0
  45. package/dist/cli/render.js.map +1 -0
  46. package/dist/cli/script_cmd.d.ts +26 -0
  47. package/dist/cli/script_cmd.d.ts.map +1 -0
  48. package/dist/cli/script_cmd.js +88 -0
  49. package/dist/cli/script_cmd.js.map +1 -0
  50. package/dist/cli/time_shim.d.ts +44 -0
  51. package/dist/cli/time_shim.d.ts.map +1 -0
  52. package/dist/cli/time_shim.js +390 -0
  53. package/dist/cli/time_shim.js.map +1 -0
  54. package/dist/cli/ts_loader.d.ts +28 -0
  55. package/dist/cli/ts_loader.d.ts.map +1 -0
  56. package/dist/cli/ts_loader.js +95 -0
  57. package/dist/cli/ts_loader.js.map +1 -0
  58. package/dist/cli/vite_helpers.d.ts +62 -0
  59. package/dist/cli/vite_helpers.d.ts.map +1 -0
  60. package/dist/cli/vite_helpers.js +273 -0
  61. package/dist/cli/vite_helpers.js.map +1 -0
  62. package/dist/index.d.ts +11 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +14 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/player/hash_router.d.ts +23 -0
  67. package/dist/player/hash_router.d.ts.map +1 -0
  68. package/dist/player/hash_router.js +49 -0
  69. package/dist/player/hash_router.js.map +1 -0
  70. package/dist/player/hud.d.ts +33 -0
  71. package/dist/player/hud.d.ts.map +1 -0
  72. package/dist/player/hud.js +357 -0
  73. package/dist/player/hud.js.map +1 -0
  74. package/dist/player/index.d.ts +123 -0
  75. package/dist/player/index.d.ts.map +1 -0
  76. package/dist/player/index.js +848 -0
  77. package/dist/player/index.js.map +1 -0
  78. package/dist/player/input.d.ts +14 -0
  79. package/dist/player/input.d.ts.map +1 -0
  80. package/dist/player/input.js +90 -0
  81. package/dist/player/input.js.map +1 -0
  82. package/dist/player/slot.d.ts +22 -0
  83. package/dist/player/slot.d.ts.map +1 -0
  84. package/dist/player/slot.js +43 -0
  85. package/dist/player/slot.js.map +1 -0
  86. package/dist/player/transitions/cut.d.ts +7 -0
  87. package/dist/player/transitions/cut.d.ts.map +1 -0
  88. package/dist/player/transitions/cut.js +9 -0
  89. package/dist/player/transitions/cut.js.map +1 -0
  90. package/dist/player/transitions/fade.d.ts +7 -0
  91. package/dist/player/transitions/fade.d.ts.map +1 -0
  92. package/dist/player/transitions/fade.js +18 -0
  93. package/dist/player/transitions/fade.js.map +1 -0
  94. package/dist/player/transitions/index.d.ts +4 -0
  95. package/dist/player/transitions/index.d.ts.map +1 -0
  96. package/dist/player/transitions/index.js +4 -0
  97. package/dist/player/transitions/index.js.map +1 -0
  98. package/dist/player/transitions/slide.d.ts +6 -0
  99. package/dist/player/transitions/slide.d.ts.map +1 -0
  100. package/dist/player/transitions/slide.js +35 -0
  101. package/dist/player/transitions/slide.js.map +1 -0
  102. package/dist/script/index.d.ts +2 -0
  103. package/dist/script/index.d.ts.map +1 -0
  104. package/dist/script/index.js +2 -0
  105. package/dist/script/index.js.map +1 -0
  106. package/dist/script/script.d.ts +10 -0
  107. package/dist/script/script.d.ts.map +1 -0
  108. package/dist/script/script.js +41 -0
  109. package/dist/script/script.js.map +1 -0
  110. package/dist/segment/SegmentRunner.d.ts +52 -0
  111. package/dist/segment/SegmentRunner.d.ts.map +1 -0
  112. package/dist/segment/SegmentRunner.js +187 -0
  113. package/dist/segment/SegmentRunner.js.map +1 -0
  114. package/dist/segment/defineConfig.d.ts +6 -0
  115. package/dist/segment/defineConfig.d.ts.map +1 -0
  116. package/dist/segment/defineConfig.js +7 -0
  117. package/dist/segment/defineConfig.js.map +1 -0
  118. package/dist/segment/defineSegment.d.ts +7 -0
  119. package/dist/segment/defineSegment.d.ts.map +1 -0
  120. package/dist/segment/defineSegment.js +25 -0
  121. package/dist/segment/defineSegment.js.map +1 -0
  122. package/dist/segment/index.d.ts +5 -0
  123. package/dist/segment/index.d.ts.map +1 -0
  124. package/dist/segment/index.js +4 -0
  125. package/dist/segment/index.js.map +1 -0
  126. package/dist/timeline/index.d.ts +73 -0
  127. package/dist/timeline/index.d.ts.map +1 -0
  128. package/dist/timeline/index.js +142 -0
  129. package/dist/timeline/index.js.map +1 -0
  130. package/dist/timeline/loadAudioTrack.d.ts +18 -0
  131. package/dist/timeline/loadAudioTrack.d.ts.map +1 -0
  132. package/dist/timeline/loadAudioTrack.js +44 -0
  133. package/dist/timeline/loadAudioTrack.js.map +1 -0
  134. package/dist/timeline/loadVoiceover.d.ts +18 -0
  135. package/dist/timeline/loadVoiceover.d.ts.map +1 -0
  136. package/dist/timeline/loadVoiceover.js +38 -0
  137. package/dist/timeline/loadVoiceover.js.map +1 -0
  138. package/dist/timeline/resolveTiming.d.ts +28 -0
  139. package/dist/timeline/resolveTiming.d.ts.map +1 -0
  140. package/dist/timeline/resolveTiming.js +63 -0
  141. package/dist/timeline/resolveTiming.js.map +1 -0
  142. package/dist/timeline/validateTiming.d.ts +29 -0
  143. package/dist/timeline/validateTiming.d.ts.map +1 -0
  144. package/dist/timeline/validateTiming.js +62 -0
  145. package/dist/timeline/validateTiming.js.map +1 -0
  146. package/dist/types.d.ts +216 -0
  147. package/dist/types.d.ts.map +1 -0
  148. package/dist/types.js +6 -0
  149. package/dist/types.js.map +1 -0
  150. package/package.json +47 -0
  151. package/skill/SKILL.md +64 -0
  152. package/skill/assets/hello_world/PLAN.md +31 -0
  153. package/skill/assets/hello_world/README.md +27 -0
  154. package/skill/assets/hello_world/audio/audio_plan.md +14 -0
  155. package/skill/assets/hello_world/segments/hello_intro.ts +69 -0
  156. package/skill/assets/hello_world/segments/hello_outro.ts +71 -0
  157. package/skill/assets/hello_world/timeline.ts +15 -0
  158. package/skill/assets/hello_world/voiceover_script/script.md +10 -0
  159. package/skill/assets/install/package.json +10 -0
  160. package/skill/assets/install/tsconfig.json +23 -0
  161. package/skill/assets/styles/editorial-mono/STYLE.md +124 -0
  162. package/skill/assets/styles/editorial-mono/brand.md +85 -0
  163. package/skill/assets/styles/editorial-mono/reference/animations.jsx +752 -0
  164. package/skill/assets/styles/editorial-mono/reference/scenes.html +563 -0
  165. package/skill/assets/styles/editorial-mono/sample/bullet.ts +101 -0
  166. package/skill/assets/styles/editorial-mono/sample/content.ts +104 -0
  167. package/skill/assets/styles/editorial-mono/sample/cta.ts +113 -0
  168. package/skill/assets/styles/editorial-mono/sample/feature.ts +111 -0
  169. package/skill/assets/styles/editorial-mono/sample/grid.ts +97 -0
  170. package/skill/assets/styles/editorial-mono/sample/kinetic.ts +96 -0
  171. package/skill/assets/styles/editorial-mono/sample/section.ts +101 -0
  172. package/skill/assets/styles/editorial-mono/sample/stat.ts +128 -0
  173. package/skill/assets/styles/editorial-mono/sample/title.ts +97 -0
  174. package/skill/assets/styles/editorial-mono/sample/ui-showcase.ts +159 -0
  175. package/skill/assets/styles/editorial-mono/tokens.css +44 -0
  176. package/skill/assets/styles/iso-diagram/STYLE.md +109 -0
  177. package/skill/assets/styles/iso-diagram/brand.md +32 -0
  178. package/skill/assets/styles/iso-diagram/reference/animations.jsx +673 -0
  179. package/skill/assets/styles/iso-diagram/reference/scenes.html +427 -0
  180. package/skill/assets/styles/iso-diagram/sample/bullet.ts +144 -0
  181. package/skill/assets/styles/iso-diagram/sample/content.ts +192 -0
  182. package/skill/assets/styles/iso-diagram/sample/cta.ts +162 -0
  183. package/skill/assets/styles/iso-diagram/sample/feature.ts +205 -0
  184. package/skill/assets/styles/iso-diagram/sample/grid.ts +181 -0
  185. package/skill/assets/styles/iso-diagram/sample/kinetic.ts +102 -0
  186. package/skill/assets/styles/iso-diagram/sample/section.ts +149 -0
  187. package/skill/assets/styles/iso-diagram/sample/stat.ts +164 -0
  188. package/skill/assets/styles/iso-diagram/sample/title.ts +173 -0
  189. package/skill/assets/styles/iso-diagram/sample/ui-showcase.ts +162 -0
  190. package/skill/assets/styles/iso-diagram/tokens.css +40 -0
  191. package/skill/assets/styles/motion-engineering/STYLE.md +106 -0
  192. package/skill/assets/styles/motion-engineering/brand.md +29 -0
  193. package/skill/assets/styles/motion-engineering/reference/animations.jsx +673 -0
  194. package/skill/assets/styles/motion-engineering/reference/scenes.html +513 -0
  195. package/skill/assets/styles/motion-engineering/sample/bullet.ts +176 -0
  196. package/skill/assets/styles/motion-engineering/sample/content.ts +228 -0
  197. package/skill/assets/styles/motion-engineering/sample/cta.ts +209 -0
  198. package/skill/assets/styles/motion-engineering/sample/feature.ts +299 -0
  199. package/skill/assets/styles/motion-engineering/sample/grid.ts +190 -0
  200. package/skill/assets/styles/motion-engineering/sample/kinetic.ts +159 -0
  201. package/skill/assets/styles/motion-engineering/sample/section.ts +196 -0
  202. package/skill/assets/styles/motion-engineering/sample/stat.ts +230 -0
  203. package/skill/assets/styles/motion-engineering/sample/title.ts +219 -0
  204. package/skill/assets/styles/motion-engineering/sample/ui-showcase.ts +267 -0
  205. package/skill/assets/styles/motion-engineering/tokens.css +40 -0
  206. package/skill/assets/styles/neon-terminal/STYLE.md +105 -0
  207. package/skill/assets/styles/neon-terminal/brand.md +27 -0
  208. package/skill/assets/styles/neon-terminal/reference/animations.jsx +673 -0
  209. package/skill/assets/styles/neon-terminal/reference/scenes.html +387 -0
  210. package/skill/assets/styles/neon-terminal/sample/bullet.ts +113 -0
  211. package/skill/assets/styles/neon-terminal/sample/content.ts +117 -0
  212. package/skill/assets/styles/neon-terminal/sample/cta.ts +131 -0
  213. package/skill/assets/styles/neon-terminal/sample/feature.ts +112 -0
  214. package/skill/assets/styles/neon-terminal/sample/grid.ts +128 -0
  215. package/skill/assets/styles/neon-terminal/sample/kinetic.ts +105 -0
  216. package/skill/assets/styles/neon-terminal/sample/section.ts +96 -0
  217. package/skill/assets/styles/neon-terminal/sample/stat.ts +123 -0
  218. package/skill/assets/styles/neon-terminal/sample/title.ts +122 -0
  219. package/skill/assets/styles/neon-terminal/sample/ui-showcase.ts +127 -0
  220. package/skill/assets/styles/neon-terminal/tokens.css +39 -0
  221. package/skill/assets/styles/risograph/STYLE.md +110 -0
  222. package/skill/assets/styles/risograph/brand.md +26 -0
  223. package/skill/assets/styles/risograph/reference/animations.jsx +673 -0
  224. package/skill/assets/styles/risograph/reference/scenes.html +403 -0
  225. package/skill/assets/styles/risograph/sample/bullet.ts +124 -0
  226. package/skill/assets/styles/risograph/sample/content.ts +135 -0
  227. package/skill/assets/styles/risograph/sample/cta.ts +149 -0
  228. package/skill/assets/styles/risograph/sample/feature.ts +152 -0
  229. package/skill/assets/styles/risograph/sample/grid.ts +123 -0
  230. package/skill/assets/styles/risograph/sample/kinetic.ts +125 -0
  231. package/skill/assets/styles/risograph/sample/section.ts +130 -0
  232. package/skill/assets/styles/risograph/sample/stat.ts +145 -0
  233. package/skill/assets/styles/risograph/sample/title.ts +132 -0
  234. package/skill/assets/styles/risograph/sample/ui-showcase.ts +147 -0
  235. package/skill/assets/styles/risograph/tokens.css +39 -0
  236. package/skill/assets/styles/swiss-console/STYLE.md +107 -0
  237. package/skill/assets/styles/swiss-console/brand.md +37 -0
  238. package/skill/assets/styles/swiss-console/reference/animations.jsx +673 -0
  239. package/skill/assets/styles/swiss-console/reference/scenes.html +420 -0
  240. package/skill/assets/styles/swiss-console/sample/bullet.ts +122 -0
  241. package/skill/assets/styles/swiss-console/sample/content.ts +137 -0
  242. package/skill/assets/styles/swiss-console/sample/cta.ts +109 -0
  243. package/skill/assets/styles/swiss-console/sample/feature.ts +163 -0
  244. package/skill/assets/styles/swiss-console/sample/grid.ts +145 -0
  245. package/skill/assets/styles/swiss-console/sample/kinetic.ts +117 -0
  246. package/skill/assets/styles/swiss-console/sample/section.ts +127 -0
  247. package/skill/assets/styles/swiss-console/sample/stat.ts +148 -0
  248. package/skill/assets/styles/swiss-console/sample/title.ts +148 -0
  249. package/skill/assets/styles/swiss-console/sample/ui-showcase.ts +198 -0
  250. package/skill/assets/styles/swiss-console/tokens.css +39 -0
  251. package/skill/install/INSTALL.md +400 -0
  252. package/skill/references/audio/audio_plan.md +199 -0
  253. package/skill/references/audio/build.md +208 -0
  254. package/skill/references/audio/cue_template.md +219 -0
  255. package/skill/references/audio/ffmpeg_cookbook.md +267 -0
  256. package/skill/references/audio/music/music.md +171 -0
  257. package/skill/references/audio/music/providers/elevenlabs.md +170 -0
  258. package/skill/references/audio/music/providers/manual.md +140 -0
  259. package/skill/references/audio/music/providers/openverse.md +265 -0
  260. package/skill/references/audio/sfx/providers/elevenlabs.md +152 -0
  261. package/skill/references/audio/sfx/providers/manual.md +117 -0
  262. package/skill/references/audio/sfx/providers/openverse.md +243 -0
  263. package/skill/references/audio/sfx/sfx.md +149 -0
  264. package/skill/references/audio/styles.md +102 -0
  265. package/skill/references/audio/sync.md +237 -0
  266. package/skill/references/audio/voiceover/animation_sync.md +142 -0
  267. package/skill/references/audio/voiceover/provider_script.md +153 -0
  268. package/skill/references/audio/voiceover/providers/elevenlabs.md +288 -0
  269. package/skill/references/audio/voiceover/providers/manual.md +100 -0
  270. package/skill/references/audio/voiceover/script_writing.md +100 -0
  271. package/skill/references/audio/voiceover/style_intake.md +56 -0
  272. package/skill/references/audio/voiceover/sync_algorithm.md +167 -0
  273. package/skill/references/audio/voiceover.md +296 -0
  274. package/skill/references/audio.md +135 -0
  275. package/skill/references/authoring_segment.md +446 -0
  276. package/skill/references/create_or_edit_video.md +232 -0
  277. package/skill/references/dev_server.md +157 -0
  278. package/skill/references/export.md +145 -0
  279. package/skill/references/new_video.md +117 -0
  280. package/skill/references/project_structure.md +144 -0
  281. package/skill/references/setup.md +109 -0
  282. package/skill/references/setup_new_style.md +158 -0
  283. package/skill/references/styles.md +154 -0
  284. package/skill/references/testing.md +115 -0
  285. package/skill/references/types.md +240 -0
  286. package/src/cli/entry/components/copy_button.ts +42 -0
  287. package/src/cli/entry/components/download_modal.ts +204 -0
  288. package/src/cli/entry/components/empty_state.ts +55 -0
  289. package/src/cli/entry/components/hide_hud_tab.ts +37 -0
  290. package/src/cli/entry/components/icons.ts +31 -0
  291. package/src/cli/entry/components/top_bar.ts +69 -0
  292. package/src/cli/entry/components/video_card.ts +57 -0
  293. package/src/cli/entry/dev_frame.ts +189 -0
  294. package/src/cli/entry/entry_index.ts +16 -0
  295. package/src/cli/entry/entry_video.ts +24 -0
  296. package/src/cli/entry/index.html +12 -0
  297. package/src/cli/entry/parse_slug.ts +14 -0
  298. package/src/cli/entry/render.html +17 -0
  299. package/src/cli/entry/render_entry.ts +121 -0
  300. package/src/cli/entry/styles/base.css +45 -0
  301. package/src/cli/entry/styles/components.css +605 -0
  302. package/src/cli/entry/styles/tokens.css +44 -0
  303. package/src/cli/entry/video.html +22 -0
  304. package/src/cli/entry/views/homepage.ts +66 -0
  305. package/src/cli/entry/views/video_view.ts +286 -0
  306. package/src/cli/entry/virtual.d.ts +8 -0
@@ -0,0 +1,848 @@
1
+ /**
2
+ * Player runtime.
3
+ * Orchestrates segment lifecycle, transitions, input, hash routing, and HUD.
4
+ */
5
+ import { SegmentRunner } from "../segment/SegmentRunner.js";
6
+ import * as hashRouter from "./hash_router.js";
7
+ import { createHud } from "./hud.js";
8
+ import { attachInput } from "./input.js";
9
+ import { clearSlotAnimations, createSlot, getSlotContent } from "./slot.js";
10
+ export class Player {
11
+ host;
12
+ hostWrapper;
13
+ options;
14
+ state = "idle";
15
+ timeline = null;
16
+ segmentLoaders = null;
17
+ transitionLoaders = null;
18
+ slotA;
19
+ slotB;
20
+ currentSlot = "a";
21
+ hud;
22
+ cleanupInput = null;
23
+ cleanupHashChange = null;
24
+ startedAt = 0;
25
+ started = false;
26
+ transitioning = false;
27
+ // Audio and playback mode state
28
+ audioEl = null;
29
+ _playbackMode = "idle";
30
+ autoAdvanceTimer = null;
31
+ /** Drift tolerance in ms: snap audio if it drifts beyond this threshold. */
32
+ static DRIFT_TOLERANCE_MS = 200;
33
+ constructor(host, options) {
34
+ this.host = host;
35
+ this.options = {
36
+ hud: options?.hud ?? !options?.renderMode,
37
+ renderMode: options?.renderMode ?? false,
38
+ fps: options?.fps ?? 60,
39
+ audioFile: options?.audioFile,
40
+ resolvedTiming: options?.resolvedTiming,
41
+ };
42
+ this.hostWrapper = document.createElement("div");
43
+ this.hostWrapper.className = "vw-host";
44
+ this.hostWrapper.setAttribute("style", "position:relative;width:100%;height:100%;overflow:hidden;");
45
+ const slotElA = createSlot("a");
46
+ const slotElB = createSlot("b");
47
+ slotElB.style.visibility = "hidden";
48
+ this.slotA = { el: slotElA, runner: null, timelineIndex: -1 };
49
+ this.slotB = { el: slotElB, runner: null, timelineIndex: -1 };
50
+ this.hostWrapper.appendChild(slotElA);
51
+ this.hostWrapper.appendChild(slotElB);
52
+ // Create audio element if an audio file is provided (not in render mode)
53
+ if (this.options.audioFile && !this.options.renderMode) {
54
+ this.audioEl = document.createElement("audio");
55
+ this.audioEl.preload = "auto";
56
+ this.audioEl.src = this.options.audioFile;
57
+ // Keep the element in the DOM but hidden
58
+ this.audioEl.style.display = "none";
59
+ this.hostWrapper.appendChild(this.audioEl);
60
+ }
61
+ this.hud = createHud({ onPlayToggle: () => this.togglePlayback() });
62
+ this.hostWrapper.appendChild(this.hud.el);
63
+ this.host.appendChild(this.hostWrapper);
64
+ if (!this.options.hud) {
65
+ this.hud.hide();
66
+ }
67
+ // In render mode, suppress all interactive input
68
+ if (!this.options.renderMode) {
69
+ this.cleanupInput = attachInput(this.hostWrapper, (cmd) => this.handleCommand(cmd));
70
+ }
71
+ }
72
+ async load(timeline, segmentLoaders, transitionLoaders) {
73
+ this.state = "loading";
74
+ this.timeline = timeline;
75
+ this.segmentLoaders = segmentLoaders;
76
+ this.transitionLoaders = transitionLoaders ?? new Map();
77
+ // Validate all segment ids exist in the loader map
78
+ for (const entry of timeline.segments) {
79
+ if (!segmentLoaders.has(entry.id)) {
80
+ const err = new Error(`Timeline "${timeline.meta.title}" references unknown segment "${entry.id}"`);
81
+ err.name = "TimelineError";
82
+ this.setError(entry.id, err);
83
+ throw err;
84
+ }
85
+ }
86
+ }
87
+ async start() {
88
+ if (this.started)
89
+ return; // no-op on second call
90
+ this.started = true;
91
+ this.startedAt = performance.now();
92
+ if (!this.timeline || !this.segmentLoaders) {
93
+ throw new Error("Player: call load() before start()");
94
+ }
95
+ // Determine starting position from hash or query
96
+ let targetIndex = 0;
97
+ let targetBeat = 0;
98
+ const hashState = hashRouter.read();
99
+ if (hashState) {
100
+ const idx = this.findSegmentIndex(hashState.segmentId);
101
+ if (idx >= 0) {
102
+ targetIndex = idx;
103
+ targetBeat = hashState.beat;
104
+ }
105
+ }
106
+ else {
107
+ const queryId = hashRouter.readQueryFallback();
108
+ if (queryId) {
109
+ const idx = this.findSegmentIndex(queryId);
110
+ if (idx >= 0) {
111
+ targetIndex = idx;
112
+ }
113
+ }
114
+ }
115
+ this.cleanupHashChange = hashRouter.onChange((s) => this.onExternalHashChange(s));
116
+ try {
117
+ await this.mountSegmentAt(targetIndex, targetBeat);
118
+ this.state = "playing";
119
+ this.broadcastState();
120
+ }
121
+ catch (e) {
122
+ this.handleLifecycleError(this.timeline.segments[targetIndex]?.id ?? "unknown", e);
123
+ }
124
+ }
125
+ destroy() {
126
+ // Stop auto-advance and audio
127
+ this.cancelAutoAdvance();
128
+ if (this.audioEl) {
129
+ this.audioEl.pause();
130
+ this.audioEl.remove();
131
+ this.audioEl = null;
132
+ }
133
+ // Unmount current runner
134
+ const current = this.getCurrentSlot();
135
+ if (current.runner) {
136
+ current.runner.unmount();
137
+ current.runner = null;
138
+ }
139
+ const other = this.getOtherSlot();
140
+ if (other.runner) {
141
+ other.runner.unmount();
142
+ other.runner = null;
143
+ }
144
+ if (this.cleanupInput) {
145
+ this.cleanupInput();
146
+ this.cleanupInput = null;
147
+ }
148
+ if (this.cleanupHashChange) {
149
+ this.cleanupHashChange();
150
+ this.cleanupHashChange = null;
151
+ }
152
+ this.hud.destroy();
153
+ this.hostWrapper.remove();
154
+ this.state = "idle";
155
+ this.broadcastState();
156
+ }
157
+ // --- Accessors for testing ---
158
+ get currentState() {
159
+ return this.state;
160
+ }
161
+ get currentSegmentId() {
162
+ const slot = this.getCurrentSlot();
163
+ return slot.runner?.segment.id ?? null;
164
+ }
165
+ get currentTimelineIndex() {
166
+ return this.getCurrentSlot().timelineIndex;
167
+ }
168
+ get isEnded() {
169
+ return this.state === "ended";
170
+ }
171
+ get isTransitioning() {
172
+ return this.transitioning;
173
+ }
174
+ get playbackMode() {
175
+ return this._playbackMode;
176
+ }
177
+ // --- Playback mode (auto-advance with audio) ---
178
+ togglePlayback() {
179
+ if (this.options.renderMode)
180
+ return;
181
+ if (this.state === "errored")
182
+ return;
183
+ if (this._playbackMode === "idle") {
184
+ // enterPlaying triggers a segment restart via transitionTo, which
185
+ // sets transitioning=true. If we're already mid-transition (e.g. the
186
+ // user presses Play during a forward nav), skip — the transition
187
+ // will complete and the user can try again.
188
+ if (this.transitioning)
189
+ return;
190
+ this.enterPlaying();
191
+ }
192
+ else {
193
+ // Pausing is always safe — it just stops audio/timers. We allow it
194
+ // even during the brief restart transition so the user can immediately
195
+ // cancel a Play they just started.
196
+ this.enterIdle();
197
+ }
198
+ }
199
+ enterPlaying() {
200
+ if (this.state === "ended")
201
+ return;
202
+ this._playbackMode = "playing";
203
+ this.updateHud();
204
+ // Restart the current segment from beat 0 so animation and audio are
205
+ // aligned from the beginning. Uses transitionTo which handles the
206
+ // dual-slot swap cleanly (including same-segment unmount-first ordering).
207
+ const currentIndex = this.getCurrentSlot().timelineIndex;
208
+ this.transitionTo(currentIndex, "forward")
209
+ .then(() => {
210
+ // After restart, only proceed if we're still in playing mode
211
+ // (user may have toggled back to idle during the await).
212
+ if (this._playbackMode !== "playing")
213
+ return;
214
+ // Sync audio to the start of the current segment and play
215
+ if (this.audioEl) {
216
+ const logicalTime = this.computeLogicalAudioTime();
217
+ this.audioEl.currentTime = logicalTime;
218
+ this.audioEl.play().catch(() => {
219
+ // Browser may block autoplay; the user clicked play so it should work.
220
+ // If it fails, we still auto-advance silently.
221
+ });
222
+ }
223
+ this.scheduleNextAutoAdvance();
224
+ })
225
+ .catch((e) => {
226
+ const segId = this.timeline?.segments[currentIndex]?.id ?? "unknown";
227
+ this.handleLifecycleError(segId, e);
228
+ });
229
+ }
230
+ enterIdle() {
231
+ this._playbackMode = "idle";
232
+ this.cancelAutoAdvance();
233
+ if (this.audioEl) {
234
+ this.audioEl.pause();
235
+ }
236
+ this.updateHud();
237
+ }
238
+ cancelAutoAdvance() {
239
+ if (this.autoAdvanceTimer !== null) {
240
+ clearTimeout(this.autoAdvanceTimer);
241
+ this.autoAdvanceTimer = null;
242
+ }
243
+ }
244
+ /**
245
+ * Compute the logical audio time based on where we are in the timeline.
246
+ * Walks the resolved timing from segment 0 up to the current segment + beat.
247
+ */
248
+ computeLogicalAudioTime() {
249
+ if (!this.timeline || !this.options.resolvedTiming)
250
+ return 0;
251
+ const currentIndex = this.getCurrentSlot().timelineIndex;
252
+ const currentBeat = this.getCurrentSlot().runner?.currentBeat ?? 0;
253
+ let elapsed = 0;
254
+ for (let i = 0; i < this.timeline.segments.length; i++) {
255
+ const segId = this.timeline.segments[i].id;
256
+ const advances = this.options.resolvedTiming[segId];
257
+ if (i < currentIndex) {
258
+ if (advances && advances.length > 0) {
259
+ elapsed += advances[advances.length - 1];
260
+ }
261
+ }
262
+ else if (i === currentIndex) {
263
+ if (advances && currentBeat > 0 && currentBeat <= advances.length) {
264
+ elapsed += advances[currentBeat - 1];
265
+ }
266
+ break;
267
+ }
268
+ }
269
+ return elapsed;
270
+ }
271
+ /**
272
+ * Compute the time until the next advance in the current segment, then schedule it.
273
+ *
274
+ * @param driftCorrectionMs - Signed milliseconds to add to the delay.
275
+ * Positive = schedule is ahead of audio, so delay is lengthened.
276
+ * Negative = schedule is behind audio, so delay is shortened.
277
+ * Passed from autoAdvanceTick when drift exceeds DRIFT_TOLERANCE_MS.
278
+ */
279
+ scheduleNextAutoAdvance(driftCorrectionMs = 0) {
280
+ if (this._playbackMode !== "playing")
281
+ return;
282
+ if (this.state === "ended" || this.state === "errored")
283
+ return;
284
+ if (!this.timeline || !this.options.resolvedTiming)
285
+ return;
286
+ const slot = this.getCurrentSlot();
287
+ if (!slot.runner)
288
+ return;
289
+ const segId = this.timeline.segments[slot.timelineIndex]?.id;
290
+ if (!segId)
291
+ return;
292
+ const advances = this.options.resolvedTiming[segId];
293
+ if (!advances || advances.length === 0)
294
+ return;
295
+ const currentBeat = slot.runner.currentBeat;
296
+ // Next advance index = currentBeat (0-based: beat 0 means advance[0] fires next)
297
+ if (currentBeat >= advances.length) {
298
+ // Segment exhausted -- this shouldn't happen if handleNext transitions properly
299
+ return;
300
+ }
301
+ // Time of the current beat (start of this beat) and the next advance
302
+ const currentBeatTime = currentBeat > 0 ? advances[currentBeat - 1] : 0;
303
+ const nextAdvanceTime = advances[currentBeat];
304
+ const delayMs = (nextAdvanceTime - currentBeatTime) * 1000 + driftCorrectionMs;
305
+ if (delayMs <= 0) {
306
+ // Fire immediately
307
+ this.autoAdvanceTick();
308
+ return;
309
+ }
310
+ this.autoAdvanceTimer = setTimeout(() => {
311
+ this.autoAdvanceTick();
312
+ }, delayMs);
313
+ }
314
+ async autoAdvanceTick() {
315
+ this.autoAdvanceTimer = null;
316
+ if (this._playbackMode !== "playing")
317
+ return;
318
+ if (this.state === "ended" || this.state === "errored")
319
+ return;
320
+ // Determine whether this tick is the segment's final advance BEFORE
321
+ // calling handleNext, so we can force a transition even when
322
+ // triggerNext() drains a pending waitForNext resolver.
323
+ const slot = this.getCurrentSlot();
324
+ const segId = this.timeline?.segments[slot.timelineIndex]?.id;
325
+ const advances = segId ? this.options.resolvedTiming?.[segId] : undefined;
326
+ const beatBefore = slot.runner?.currentBeat ?? 0;
327
+ const isLastAdvance = advances != null && beatBefore + 1 >= advances.length;
328
+ await this.handleNext(isLastAdvance);
329
+ // Compute signed drift between audio element and expected timeline
330
+ // position. Instead of seeking the audio (which causes an audible
331
+ // click/stutter), we adjust the next scheduled tick delay so the
332
+ // scheduler realigns with the audio clock.
333
+ let driftCorrectionMs = 0;
334
+ if (this.audioEl && this.options.resolvedTiming) {
335
+ const expectedTime = this.computeLogicalAudioTime();
336
+ const actualTime = this.audioEl.currentTime;
337
+ const drift = expectedTime - actualTime; // positive = schedule ahead of audio
338
+ if (Math.abs(drift) > Player.DRIFT_TOLERANCE_MS / 1000) {
339
+ driftCorrectionMs = drift * 1000;
340
+ }
341
+ }
342
+ // If still playing, schedule the next one.
343
+ // Cast needed: TS narrows this.state from the early guard, but
344
+ // handleNext() can mutate it to "ended" at runtime.
345
+ if (this._playbackMode === "playing" && this.state !== "ended") {
346
+ this.scheduleNextAutoAdvance(driftCorrectionMs);
347
+ }
348
+ }
349
+ /**
350
+ * Advance one beat in render mode. Returns false when the timeline is exhausted.
351
+ * Only valid when the player was constructed with renderMode: true.
352
+ *
353
+ * @param isLast - true when this is the final scheduled advance for the
354
+ * current segment. On the last beat the method always transitions to the
355
+ * next segment (after draining any pending waitForNext resolver).
356
+ *
357
+ * Transitions are NOT awaited — WAAPI animations are driven by the JS time
358
+ * shim's virtual clock, so they complete as the render driver advances time.
359
+ * Awaiting transition completion here would deadlock because the transition's
360
+ * `.finished` promises only resolve when the shim advances the clock.
361
+ */
362
+ async renderAdvance(isLast) {
363
+ if (!this.options.renderMode) {
364
+ throw new Error("renderAdvance() is only valid in render mode");
365
+ }
366
+ if (this.state === "ended" || this.state === "errored") {
367
+ return false;
368
+ }
369
+ const slot = this.getCurrentSlot();
370
+ if (!slot.runner)
371
+ return false;
372
+ // Always drain any pending waitForNext resolver for this beat.
373
+ const drained = slot.runner.triggerNext();
374
+ if (!isLast) {
375
+ if (!drained) {
376
+ const segId = this.timeline?.segments[slot.timelineIndex]?.id ?? "unknown";
377
+ console.debug(`[renderAdvance] triggerNext() had no pending resolver for segment "${segId}" — advances may exceed waitForNext calls`);
378
+ }
379
+ // Internal beat — stay in this segment.
380
+ return true;
381
+ }
382
+ // Last beat for this segment — transition out.
383
+ const nextIndex = slot.timelineIndex + 1;
384
+ if (!this.timeline || nextIndex >= this.timeline.segments.length) {
385
+ this.state = "ended";
386
+ this.broadcastState();
387
+ return false;
388
+ }
389
+ // Start the transition but do NOT await it. WAAPI animations are driven
390
+ // by the virtual clock shim — awaiting here would deadlock because the
391
+ // transition's .finished promises only resolve when __VW_ADVANCE_CLOCK__
392
+ // is called by the render driver.
393
+ this.transitionTo(nextIndex, "forward").catch((e) => {
394
+ const segId = this.timeline?.segments[nextIndex]?.id ?? "unknown";
395
+ this.handleLifecycleError(segId, e);
396
+ });
397
+ return true;
398
+ }
399
+ // --- Private: navigation ---
400
+ async handleCommand(cmd) {
401
+ if (this.state === "errored")
402
+ return;
403
+ if (typeof cmd === "string") {
404
+ switch (cmd) {
405
+ case "next":
406
+ // Manual nav pauses playback
407
+ if (this._playbackMode === "playing") {
408
+ this.enterIdle();
409
+ }
410
+ await this.handleNext();
411
+ break;
412
+ case "prev":
413
+ // Manual nav pauses playback
414
+ if (this._playbackMode === "playing") {
415
+ this.enterIdle();
416
+ }
417
+ await this.handlePrev();
418
+ break;
419
+ case "restart":
420
+ if (this._playbackMode === "playing") {
421
+ this.enterIdle();
422
+ }
423
+ await this.handleRestart();
424
+ break;
425
+ case "toggleHud":
426
+ this.hud.toggle();
427
+ break;
428
+ case "togglePlay":
429
+ this.togglePlayback();
430
+ break;
431
+ }
432
+ }
433
+ else if (cmd.kind === "jumpTo") {
434
+ if (this._playbackMode === "playing") {
435
+ this.enterIdle();
436
+ }
437
+ await this.jumpToIndex(cmd.index);
438
+ }
439
+ }
440
+ /**
441
+ * Advance one beat. In manual-nav mode (isLastAdvance omitted / false),
442
+ * draining a pending waitForNext resolver counts as consuming the press
443
+ * and the player stays on the current segment. When called from
444
+ * autoAdvanceTick with isLastAdvance=true, the method forces a segment
445
+ * transition after draining the resolver -- otherwise the last scheduled
446
+ * advance would be consumed without ever transitioning out.
447
+ */
448
+ async handleNext(isLastAdvance = false) {
449
+ if (this.state !== "playing" || this.transitioning)
450
+ return;
451
+ const slot = this.getCurrentSlot();
452
+ if (!slot.runner)
453
+ return;
454
+ // Let the segment handle the press first
455
+ const consumed = slot.runner.triggerNext();
456
+ if (consumed && !isLastAdvance) {
457
+ hashRouter.write({
458
+ segmentId: slot.runner.segment.id,
459
+ beat: slot.runner.currentBeat,
460
+ });
461
+ this.updateHud();
462
+ return;
463
+ }
464
+ // Segment didn't consume, or this was the last scheduled advance
465
+ // for the segment -- transition to next segment.
466
+ const nextIndex = slot.timelineIndex + 1;
467
+ if (!this.timeline || nextIndex >= this.timeline.segments.length) {
468
+ this.state = "ended";
469
+ // Stop audio and playback on end
470
+ if (this._playbackMode === "playing") {
471
+ this.enterIdle();
472
+ }
473
+ this.updateHud();
474
+ this.broadcastState();
475
+ return;
476
+ }
477
+ await this.transitionTo(nextIndex, "forward");
478
+ }
479
+ async handlePrev() {
480
+ if (this.state !== "playing" && this.state !== "ended")
481
+ return;
482
+ if (this.transitioning)
483
+ return;
484
+ if (this.state === "ended") {
485
+ this.state = "playing";
486
+ }
487
+ const slot = this.getCurrentSlot();
488
+ if (!slot.runner)
489
+ return;
490
+ // Let segment handle prev first
491
+ const consumed = slot.runner.triggerPrev();
492
+ if (consumed) {
493
+ this.updateHud();
494
+ return;
495
+ }
496
+ // If at first segment, no-op
497
+ if (slot.timelineIndex <= 0)
498
+ return;
499
+ await this.transitionTo(slot.timelineIndex - 1, "backward");
500
+ }
501
+ async handleRestart() {
502
+ if (this.transitioning)
503
+ return;
504
+ if (!this.timeline || this.timeline.segments.length === 0)
505
+ return;
506
+ if (this.state === "ended") {
507
+ this.state = "playing";
508
+ }
509
+ // Always remount segment 0, even if already there
510
+ await this.transitionTo(0, "forward");
511
+ }
512
+ async jumpToIndex(index) {
513
+ if (!this.timeline)
514
+ return;
515
+ if (index < 0 || index >= this.timeline.segments.length)
516
+ return;
517
+ if (this.transitioning)
518
+ return;
519
+ const currentSlot = this.getCurrentSlot();
520
+ // Number keys at the same index are a no-op (use R to restart)
521
+ if (currentSlot.timelineIndex === index && this.state !== "ended") {
522
+ return;
523
+ }
524
+ if (this.state === "ended") {
525
+ this.state = "playing";
526
+ }
527
+ await this.transitionTo(index, "forward");
528
+ }
529
+ async transitionTo(targetIndex, direction) {
530
+ if (!this.timeline || !this.segmentLoaders || this.transitioning)
531
+ return;
532
+ this.transitioning = true;
533
+ this.state = "transitioning";
534
+ this.broadcastState();
535
+ const outgoingSlot = this.getCurrentSlot();
536
+ const incomingSlot = this.getOtherSlot();
537
+ const entry = this.timeline.segments[targetIndex];
538
+ // Detect same-segment transition (restart). When the outgoing segment
539
+ // module is the same object (Vite caches imports), we must unmount the
540
+ // outgoing BEFORE mounting the incoming. Otherwise the new mount() sets
541
+ // module-level state (e.g. `host = el`), and the subsequent unmount()
542
+ // nulls it, leaving the new play() with torn-down state.
543
+ const outgoingEntryId = this.timeline.segments[outgoingSlot.timelineIndex]?.id;
544
+ const isSameSegment = outgoingSlot.runner != null && outgoingEntryId === entry.id;
545
+ try {
546
+ // Load segment module
547
+ const loader = this.segmentLoaders.get(entry.id);
548
+ if (!loader)
549
+ throw new Error(`No loader for segment "${entry.id}"`);
550
+ const mod = await loader();
551
+ const segment = mod.default;
552
+ const outgoingRunner = outgoingSlot.runner;
553
+ // For same-segment transitions, unmount outgoing FIRST to avoid
554
+ // the cached-module shared-state conflict described above.
555
+ if (isSameSegment && outgoingRunner) {
556
+ outgoingRunner.unmount();
557
+ outgoingSlot.runner = null;
558
+ if (outgoingRunner.playPromise) {
559
+ await outgoingRunner.playPromise;
560
+ }
561
+ }
562
+ // Create runner and mount
563
+ const runnerMode = this.options.renderMode ? "render" : "interactive";
564
+ const runner = new SegmentRunner(segment, { mode: runnerMode });
565
+ incomingSlot.runner = runner;
566
+ incomingSlot.timelineIndex = targetIndex;
567
+ // Clear stale WAAPI transition animations before reuse
568
+ clearSlotAnimations(incomingSlot.el);
569
+ const incomingContent = getSlotContent(incomingSlot.el);
570
+ incomingContent.innerHTML = "";
571
+ incomingSlot.el.style.visibility = "visible";
572
+ await runner.mount(incomingContent);
573
+ // Start play on incoming (don't await -- it resolves when segment finishes)
574
+ const incomingPlayPromise = runner.startPlay();
575
+ // For different-segment transitions, unmount outgoing AFTER mounting
576
+ // incoming (standard dual-slot crossfade ordering).
577
+ if (!isSameSegment && outgoingRunner) {
578
+ outgoingRunner.unmount();
579
+ outgoingSlot.runner = null;
580
+ if (outgoingRunner.playPromise) {
581
+ await outgoingRunner.playPromise;
582
+ }
583
+ }
584
+ // Run transition (skip for same-segment restarts -- no visual
585
+ // transition needed when restarting the current segment)
586
+ if (!isSameSegment) {
587
+ const transitionFn = await this.resolveTransition(entry, direction);
588
+ if (transitionFn && outgoingRunner) {
589
+ await transitionFn(outgoingSlot.el, incomingSlot.el, {
590
+ direction,
591
+ duration: this.getTransitionDuration(entry),
592
+ });
593
+ }
594
+ }
595
+ // Clear transition animations on both slots after transition completes.
596
+ // Slide transitions use fill:"forwards" which persists transforms.
597
+ clearSlotAnimations(outgoingSlot.el);
598
+ clearSlotAnimations(incomingSlot.el);
599
+ outgoingSlot.el.style.visibility = "hidden";
600
+ getSlotContent(outgoingSlot.el).innerHTML = "";
601
+ // Swap slots
602
+ this.currentSlot = this.currentSlot === "a" ? "b" : "a";
603
+ hashRouter.write({ segmentId: entry.id, beat: 0 });
604
+ this.state = "playing";
605
+ this.transitioning = false;
606
+ this.updateHud();
607
+ this.broadcastState();
608
+ // Monitor play promise for errors
609
+ incomingPlayPromise.catch((e) => {
610
+ this.handleLifecycleError(entry.id, e);
611
+ });
612
+ }
613
+ catch (e) {
614
+ this.transitioning = false;
615
+ this.handleLifecycleError(entry.id, e);
616
+ }
617
+ }
618
+ async mountSegmentAt(index, seekBeat = 0) {
619
+ if (!this.timeline || !this.segmentLoaders)
620
+ return;
621
+ const entry = this.timeline.segments[index];
622
+ const loader = this.segmentLoaders.get(entry.id);
623
+ if (!loader)
624
+ throw new Error(`No loader for segment "${entry.id}"`);
625
+ const mod = await loader();
626
+ const segment = mod.default;
627
+ const runnerMode = this.options.renderMode ? "render" : "interactive";
628
+ const runner = new SegmentRunner(segment, {
629
+ mode: runnerMode,
630
+ seekBeats: seekBeat,
631
+ });
632
+ const slot = this.getCurrentSlot();
633
+ slot.runner = runner;
634
+ slot.timelineIndex = index;
635
+ const slotContent = getSlotContent(slot.el);
636
+ slotContent.innerHTML = "";
637
+ slot.el.style.visibility = "visible";
638
+ await runner.mount(slotContent);
639
+ // Start play (don't await -- segment controls its own duration)
640
+ const playPromise = runner.startPlay();
641
+ playPromise.catch((e) => {
642
+ this.handleLifecycleError(entry.id, e);
643
+ });
644
+ hashRouter.write({ segmentId: entry.id, beat: seekBeat });
645
+ this.updateHud();
646
+ }
647
+ async resolveTransition(entry, direction) {
648
+ if (!this.transitionLoaders)
649
+ return null;
650
+ // Backward always uses cut
651
+ if (direction === "backward") {
652
+ const cutLoader = this.transitionLoaders.get("cut");
653
+ if (cutLoader) {
654
+ const mod = await cutLoader();
655
+ return mod.default;
656
+ }
657
+ return null;
658
+ }
659
+ if (!entry.transition) {
660
+ // Default: cut
661
+ const cutLoader = this.transitionLoaders.get("cut");
662
+ if (cutLoader) {
663
+ const mod = await cutLoader();
664
+ return mod.default;
665
+ }
666
+ return null;
667
+ }
668
+ const name = typeof entry.transition === "string" ? entry.transition : entry.transition.type;
669
+ const loader = this.transitionLoaders.get(name);
670
+ if (!loader) {
671
+ console.warn(`Transition "${name}" not found, falling back to cut`);
672
+ const cutLoader = this.transitionLoaders.get("cut");
673
+ if (cutLoader) {
674
+ const mod = await cutLoader();
675
+ return mod.default;
676
+ }
677
+ return null;
678
+ }
679
+ const mod = await loader();
680
+ return mod.default;
681
+ }
682
+ getTransitionDuration(entry) {
683
+ if (!entry.transition || typeof entry.transition === "string")
684
+ return undefined;
685
+ return typeof entry.transition.duration === "number" ? entry.transition.duration : undefined;
686
+ }
687
+ // --- Hash change ---
688
+ onExternalHashChange(state) {
689
+ if (this.state === "errored" || this.transitioning)
690
+ return;
691
+ if (!this.timeline)
692
+ return;
693
+ const idx = this.findSegmentIndex(state.segmentId);
694
+ if (idx < 0)
695
+ return;
696
+ const currentSlot = this.getCurrentSlot();
697
+ const sameSegment = currentSlot.timelineIndex === idx;
698
+ const sameBeat = sameSegment && currentSlot.runner?.currentBeat === state.beat;
699
+ if (sameBeat)
700
+ return;
701
+ // External hash change is manual nav -- pause playback
702
+ if (this._playbackMode === "playing") {
703
+ this.enterIdle();
704
+ }
705
+ if (this.state === "ended") {
706
+ this.state = "playing";
707
+ }
708
+ // Jump to the segment + beat (remount with seek)
709
+ this.jumpToSegmentBeat(idx, state.beat).catch((e) => {
710
+ this.handleLifecycleError(state.segmentId, e);
711
+ });
712
+ }
713
+ async jumpToSegmentBeat(index, beat) {
714
+ if (!this.timeline || !this.segmentLoaders || this.transitioning)
715
+ return;
716
+ this.transitioning = true;
717
+ this.state = "transitioning";
718
+ this.broadcastState();
719
+ const entry = this.timeline.segments[index];
720
+ const outgoingSlot = this.getCurrentSlot();
721
+ const incomingSlot = this.getOtherSlot();
722
+ try {
723
+ const loader = this.segmentLoaders.get(entry.id);
724
+ if (!loader)
725
+ throw new Error(`No loader for segment "${entry.id}"`);
726
+ const mod = await loader();
727
+ const segment = mod.default;
728
+ const runnerMode = this.options.renderMode ? "render" : "interactive";
729
+ const runner = new SegmentRunner(segment, {
730
+ mode: runnerMode,
731
+ seekBeats: beat,
732
+ });
733
+ // Clear stale WAAPI transition animations before reuse
734
+ clearSlotAnimations(incomingSlot.el);
735
+ incomingSlot.runner = runner;
736
+ incomingSlot.timelineIndex = index;
737
+ const incomingContent = getSlotContent(incomingSlot.el);
738
+ incomingContent.innerHTML = "";
739
+ incomingSlot.el.style.visibility = "visible";
740
+ await runner.mount(incomingContent);
741
+ const playPromise = runner.startPlay();
742
+ // Unmount outgoing
743
+ if (outgoingSlot.runner) {
744
+ outgoingSlot.runner.unmount();
745
+ outgoingSlot.runner = null;
746
+ }
747
+ clearSlotAnimations(outgoingSlot.el);
748
+ outgoingSlot.el.style.visibility = "hidden";
749
+ getSlotContent(outgoingSlot.el).innerHTML = "";
750
+ this.currentSlot = this.currentSlot === "a" ? "b" : "a";
751
+ this.state = "playing";
752
+ this.transitioning = false;
753
+ this.updateHud();
754
+ this.broadcastState();
755
+ playPromise.catch((e) => {
756
+ this.handleLifecycleError(entry.id, e);
757
+ });
758
+ }
759
+ catch (e) {
760
+ this.transitioning = false;
761
+ this.handleLifecycleError(entry.id, e);
762
+ }
763
+ }
764
+ // --- Error handling ---
765
+ handleLifecycleError(segmentId, e) {
766
+ const error = e instanceof Error ? e : new Error(String(e));
767
+ console.error(`Player: segment "${segmentId}" errored:`, error);
768
+ this.state = "errored";
769
+ this.transitioning = false;
770
+ this.setError(segmentId, error);
771
+ this.broadcastState();
772
+ }
773
+ setError(segmentId, error) {
774
+ this.state = "errored";
775
+ this.hud.update({
776
+ segmentId,
777
+ beat: 0,
778
+ segmentTime: 0,
779
+ totalTime: this.totalElapsed(),
780
+ mode: this.options.renderMode ? "render" : "interactive",
781
+ ended: false,
782
+ error: {
783
+ segmentId,
784
+ message: error.message,
785
+ stack: error.stack,
786
+ },
787
+ });
788
+ // Force HUD visible for errors
789
+ this.hud.show();
790
+ }
791
+ // --- HUD ---
792
+ updateHud() {
793
+ const slot = this.getCurrentSlot();
794
+ const runner = slot.runner;
795
+ const segmentId = runner?.segment.id ?? "";
796
+ const beat = runner?.currentBeat ?? 0;
797
+ const voiceover = runner?.segment.voiceover;
798
+ const hudState = {
799
+ segmentId,
800
+ beat,
801
+ segmentTime: runner ? runner.elapsedSinceMount : 0,
802
+ totalTime: this.totalElapsed(),
803
+ voiceover,
804
+ mode: this.options.renderMode ? "render" : "interactive",
805
+ ended: this.state === "ended",
806
+ playbackMode: this._playbackMode,
807
+ };
808
+ this.hud.update(hudState);
809
+ }
810
+ totalElapsed() {
811
+ return this.started ? performance.now() - this.startedAt : 0;
812
+ }
813
+ // --- State broadcast ---
814
+ /**
815
+ * Set document.body.dataset.vwState to the current player state.
816
+ * Used by the render driver to detect when the player is idle (not transitioning)
817
+ * and ready for the next action.
818
+ *
819
+ * States: "idle" | "loading" | "playing" | "transitioning" | "ended" | "errored"
820
+ */
821
+ broadcastState() {
822
+ try {
823
+ if (typeof document !== "undefined" && document.body) {
824
+ document.body.dataset.vwState = this.state;
825
+ // Also broadcast current segment id for driver synchronization
826
+ const slot = this.getCurrentSlot();
827
+ const segId = slot.runner?.segment.id ?? "";
828
+ document.body.dataset.vwSegment = segId;
829
+ }
830
+ }
831
+ catch {
832
+ // Ignore in non-browser environments (jsdom may throw)
833
+ }
834
+ }
835
+ // --- Slot helpers ---
836
+ getCurrentSlot() {
837
+ return this.currentSlot === "a" ? this.slotA : this.slotB;
838
+ }
839
+ getOtherSlot() {
840
+ return this.currentSlot === "a" ? this.slotB : this.slotA;
841
+ }
842
+ findSegmentIndex(id) {
843
+ if (!this.timeline)
844
+ return -1;
845
+ return this.timeline.segments.findIndex((e) => e.id === id);
846
+ }
847
+ }
848
+ //# sourceMappingURL=index.js.map