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.
- package/README.md +91 -0
- package/dist/cli/argv.d.ts +28 -0
- package/dist/cli/argv.d.ts.map +1 -0
- package/dist/cli/argv.js +115 -0
- package/dist/cli/argv.js.map +1 -0
- package/dist/cli/bin.d.ts +7 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +10 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/dev.d.ts +19 -0
- package/dist/cli/dev.d.ts.map +1 -0
- package/dist/cli/dev.js +104 -0
- package/dist/cli/dev.js.map +1 -0
- package/dist/cli/discover.d.ts +29 -0
- package/dist/cli/discover.d.ts.map +1 -0
- package/dist/cli/discover.js +104 -0
- package/dist/cli/discover.js.map +1 -0
- package/dist/cli/discover_project.d.ts +29 -0
- package/dist/cli/discover_project.d.ts.map +1 -0
- package/dist/cli/discover_project.js +108 -0
- package/dist/cli/discover_project.js.map +1 -0
- package/dist/cli/errors.d.ts +10 -0
- package/dist/cli/errors.d.ts.map +1 -0
- package/dist/cli/errors.js +13 -0
- package/dist/cli/errors.js.map +1 -0
- package/dist/cli/ffmpeg.d.ts +57 -0
- package/dist/cli/ffmpeg.d.ts.map +1 -0
- package/dist/cli/ffmpeg.js +122 -0
- package/dist/cli/ffmpeg.js.map +1 -0
- package/dist/cli/index.d.ts +7 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +152 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/playwright_check.d.ts +44 -0
- package/dist/cli/playwright_check.d.ts.map +1 -0
- package/dist/cli/playwright_check.js +20 -0
- package/dist/cli/playwright_check.js.map +1 -0
- package/dist/cli/prompt.d.ts +13 -0
- package/dist/cli/prompt.d.ts.map +1 -0
- package/dist/cli/prompt.js +47 -0
- package/dist/cli/prompt.js.map +1 -0
- package/dist/cli/render.d.ts +60 -0
- package/dist/cli/render.d.ts.map +1 -0
- package/dist/cli/render.js +471 -0
- package/dist/cli/render.js.map +1 -0
- package/dist/cli/script_cmd.d.ts +26 -0
- package/dist/cli/script_cmd.d.ts.map +1 -0
- package/dist/cli/script_cmd.js +88 -0
- package/dist/cli/script_cmd.js.map +1 -0
- package/dist/cli/time_shim.d.ts +44 -0
- package/dist/cli/time_shim.d.ts.map +1 -0
- package/dist/cli/time_shim.js +390 -0
- package/dist/cli/time_shim.js.map +1 -0
- package/dist/cli/ts_loader.d.ts +28 -0
- package/dist/cli/ts_loader.d.ts.map +1 -0
- package/dist/cli/ts_loader.js +95 -0
- package/dist/cli/ts_loader.js.map +1 -0
- package/dist/cli/vite_helpers.d.ts +62 -0
- package/dist/cli/vite_helpers.d.ts.map +1 -0
- package/dist/cli/vite_helpers.js +273 -0
- package/dist/cli/vite_helpers.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/player/hash_router.d.ts +23 -0
- package/dist/player/hash_router.d.ts.map +1 -0
- package/dist/player/hash_router.js +49 -0
- package/dist/player/hash_router.js.map +1 -0
- package/dist/player/hud.d.ts +33 -0
- package/dist/player/hud.d.ts.map +1 -0
- package/dist/player/hud.js +357 -0
- package/dist/player/hud.js.map +1 -0
- package/dist/player/index.d.ts +123 -0
- package/dist/player/index.d.ts.map +1 -0
- package/dist/player/index.js +848 -0
- package/dist/player/index.js.map +1 -0
- package/dist/player/input.d.ts +14 -0
- package/dist/player/input.d.ts.map +1 -0
- package/dist/player/input.js +90 -0
- package/dist/player/input.js.map +1 -0
- package/dist/player/slot.d.ts +22 -0
- package/dist/player/slot.d.ts.map +1 -0
- package/dist/player/slot.js +43 -0
- package/dist/player/slot.js.map +1 -0
- package/dist/player/transitions/cut.d.ts +7 -0
- package/dist/player/transitions/cut.d.ts.map +1 -0
- package/dist/player/transitions/cut.js +9 -0
- package/dist/player/transitions/cut.js.map +1 -0
- package/dist/player/transitions/fade.d.ts +7 -0
- package/dist/player/transitions/fade.d.ts.map +1 -0
- package/dist/player/transitions/fade.js +18 -0
- package/dist/player/transitions/fade.js.map +1 -0
- package/dist/player/transitions/index.d.ts +4 -0
- package/dist/player/transitions/index.d.ts.map +1 -0
- package/dist/player/transitions/index.js +4 -0
- package/dist/player/transitions/index.js.map +1 -0
- package/dist/player/transitions/slide.d.ts +6 -0
- package/dist/player/transitions/slide.d.ts.map +1 -0
- package/dist/player/transitions/slide.js +35 -0
- package/dist/player/transitions/slide.js.map +1 -0
- package/dist/script/index.d.ts +2 -0
- package/dist/script/index.d.ts.map +1 -0
- package/dist/script/index.js +2 -0
- package/dist/script/index.js.map +1 -0
- package/dist/script/script.d.ts +10 -0
- package/dist/script/script.d.ts.map +1 -0
- package/dist/script/script.js +41 -0
- package/dist/script/script.js.map +1 -0
- package/dist/segment/SegmentRunner.d.ts +52 -0
- package/dist/segment/SegmentRunner.d.ts.map +1 -0
- package/dist/segment/SegmentRunner.js +187 -0
- package/dist/segment/SegmentRunner.js.map +1 -0
- package/dist/segment/defineConfig.d.ts +6 -0
- package/dist/segment/defineConfig.d.ts.map +1 -0
- package/dist/segment/defineConfig.js +7 -0
- package/dist/segment/defineConfig.js.map +1 -0
- package/dist/segment/defineSegment.d.ts +7 -0
- package/dist/segment/defineSegment.d.ts.map +1 -0
- package/dist/segment/defineSegment.js +25 -0
- package/dist/segment/defineSegment.js.map +1 -0
- package/dist/segment/index.d.ts +5 -0
- package/dist/segment/index.d.ts.map +1 -0
- package/dist/segment/index.js +4 -0
- package/dist/segment/index.js.map +1 -0
- package/dist/timeline/index.d.ts +73 -0
- package/dist/timeline/index.d.ts.map +1 -0
- package/dist/timeline/index.js +142 -0
- package/dist/timeline/index.js.map +1 -0
- package/dist/timeline/loadAudioTrack.d.ts +18 -0
- package/dist/timeline/loadAudioTrack.d.ts.map +1 -0
- package/dist/timeline/loadAudioTrack.js +44 -0
- package/dist/timeline/loadAudioTrack.js.map +1 -0
- package/dist/timeline/loadVoiceover.d.ts +18 -0
- package/dist/timeline/loadVoiceover.d.ts.map +1 -0
- package/dist/timeline/loadVoiceover.js +38 -0
- package/dist/timeline/loadVoiceover.js.map +1 -0
- package/dist/timeline/resolveTiming.d.ts +28 -0
- package/dist/timeline/resolveTiming.d.ts.map +1 -0
- package/dist/timeline/resolveTiming.js +63 -0
- package/dist/timeline/resolveTiming.js.map +1 -0
- package/dist/timeline/validateTiming.d.ts +29 -0
- package/dist/timeline/validateTiming.d.ts.map +1 -0
- package/dist/timeline/validateTiming.js +62 -0
- package/dist/timeline/validateTiming.js.map +1 -0
- package/dist/types.d.ts +216 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/package.json +47 -0
- package/skill/SKILL.md +64 -0
- package/skill/assets/hello_world/PLAN.md +31 -0
- package/skill/assets/hello_world/README.md +27 -0
- package/skill/assets/hello_world/audio/audio_plan.md +14 -0
- package/skill/assets/hello_world/segments/hello_intro.ts +69 -0
- package/skill/assets/hello_world/segments/hello_outro.ts +71 -0
- package/skill/assets/hello_world/timeline.ts +15 -0
- package/skill/assets/hello_world/voiceover_script/script.md +10 -0
- package/skill/assets/install/package.json +10 -0
- package/skill/assets/install/tsconfig.json +23 -0
- package/skill/assets/styles/editorial-mono/STYLE.md +124 -0
- package/skill/assets/styles/editorial-mono/brand.md +85 -0
- package/skill/assets/styles/editorial-mono/reference/animations.jsx +752 -0
- package/skill/assets/styles/editorial-mono/reference/scenes.html +563 -0
- package/skill/assets/styles/editorial-mono/sample/bullet.ts +101 -0
- package/skill/assets/styles/editorial-mono/sample/content.ts +104 -0
- package/skill/assets/styles/editorial-mono/sample/cta.ts +113 -0
- package/skill/assets/styles/editorial-mono/sample/feature.ts +111 -0
- package/skill/assets/styles/editorial-mono/sample/grid.ts +97 -0
- package/skill/assets/styles/editorial-mono/sample/kinetic.ts +96 -0
- package/skill/assets/styles/editorial-mono/sample/section.ts +101 -0
- package/skill/assets/styles/editorial-mono/sample/stat.ts +128 -0
- package/skill/assets/styles/editorial-mono/sample/title.ts +97 -0
- package/skill/assets/styles/editorial-mono/sample/ui-showcase.ts +159 -0
- package/skill/assets/styles/editorial-mono/tokens.css +44 -0
- package/skill/assets/styles/iso-diagram/STYLE.md +109 -0
- package/skill/assets/styles/iso-diagram/brand.md +32 -0
- package/skill/assets/styles/iso-diagram/reference/animations.jsx +673 -0
- package/skill/assets/styles/iso-diagram/reference/scenes.html +427 -0
- package/skill/assets/styles/iso-diagram/sample/bullet.ts +144 -0
- package/skill/assets/styles/iso-diagram/sample/content.ts +192 -0
- package/skill/assets/styles/iso-diagram/sample/cta.ts +162 -0
- package/skill/assets/styles/iso-diagram/sample/feature.ts +205 -0
- package/skill/assets/styles/iso-diagram/sample/grid.ts +181 -0
- package/skill/assets/styles/iso-diagram/sample/kinetic.ts +102 -0
- package/skill/assets/styles/iso-diagram/sample/section.ts +149 -0
- package/skill/assets/styles/iso-diagram/sample/stat.ts +164 -0
- package/skill/assets/styles/iso-diagram/sample/title.ts +173 -0
- package/skill/assets/styles/iso-diagram/sample/ui-showcase.ts +162 -0
- package/skill/assets/styles/iso-diagram/tokens.css +40 -0
- package/skill/assets/styles/motion-engineering/STYLE.md +106 -0
- package/skill/assets/styles/motion-engineering/brand.md +29 -0
- package/skill/assets/styles/motion-engineering/reference/animations.jsx +673 -0
- package/skill/assets/styles/motion-engineering/reference/scenes.html +513 -0
- package/skill/assets/styles/motion-engineering/sample/bullet.ts +176 -0
- package/skill/assets/styles/motion-engineering/sample/content.ts +228 -0
- package/skill/assets/styles/motion-engineering/sample/cta.ts +209 -0
- package/skill/assets/styles/motion-engineering/sample/feature.ts +299 -0
- package/skill/assets/styles/motion-engineering/sample/grid.ts +190 -0
- package/skill/assets/styles/motion-engineering/sample/kinetic.ts +159 -0
- package/skill/assets/styles/motion-engineering/sample/section.ts +196 -0
- package/skill/assets/styles/motion-engineering/sample/stat.ts +230 -0
- package/skill/assets/styles/motion-engineering/sample/title.ts +219 -0
- package/skill/assets/styles/motion-engineering/sample/ui-showcase.ts +267 -0
- package/skill/assets/styles/motion-engineering/tokens.css +40 -0
- package/skill/assets/styles/neon-terminal/STYLE.md +105 -0
- package/skill/assets/styles/neon-terminal/brand.md +27 -0
- package/skill/assets/styles/neon-terminal/reference/animations.jsx +673 -0
- package/skill/assets/styles/neon-terminal/reference/scenes.html +387 -0
- package/skill/assets/styles/neon-terminal/sample/bullet.ts +113 -0
- package/skill/assets/styles/neon-terminal/sample/content.ts +117 -0
- package/skill/assets/styles/neon-terminal/sample/cta.ts +131 -0
- package/skill/assets/styles/neon-terminal/sample/feature.ts +112 -0
- package/skill/assets/styles/neon-terminal/sample/grid.ts +128 -0
- package/skill/assets/styles/neon-terminal/sample/kinetic.ts +105 -0
- package/skill/assets/styles/neon-terminal/sample/section.ts +96 -0
- package/skill/assets/styles/neon-terminal/sample/stat.ts +123 -0
- package/skill/assets/styles/neon-terminal/sample/title.ts +122 -0
- package/skill/assets/styles/neon-terminal/sample/ui-showcase.ts +127 -0
- package/skill/assets/styles/neon-terminal/tokens.css +39 -0
- package/skill/assets/styles/risograph/STYLE.md +110 -0
- package/skill/assets/styles/risograph/brand.md +26 -0
- package/skill/assets/styles/risograph/reference/animations.jsx +673 -0
- package/skill/assets/styles/risograph/reference/scenes.html +403 -0
- package/skill/assets/styles/risograph/sample/bullet.ts +124 -0
- package/skill/assets/styles/risograph/sample/content.ts +135 -0
- package/skill/assets/styles/risograph/sample/cta.ts +149 -0
- package/skill/assets/styles/risograph/sample/feature.ts +152 -0
- package/skill/assets/styles/risograph/sample/grid.ts +123 -0
- package/skill/assets/styles/risograph/sample/kinetic.ts +125 -0
- package/skill/assets/styles/risograph/sample/section.ts +130 -0
- package/skill/assets/styles/risograph/sample/stat.ts +145 -0
- package/skill/assets/styles/risograph/sample/title.ts +132 -0
- package/skill/assets/styles/risograph/sample/ui-showcase.ts +147 -0
- package/skill/assets/styles/risograph/tokens.css +39 -0
- package/skill/assets/styles/swiss-console/STYLE.md +107 -0
- package/skill/assets/styles/swiss-console/brand.md +37 -0
- package/skill/assets/styles/swiss-console/reference/animations.jsx +673 -0
- package/skill/assets/styles/swiss-console/reference/scenes.html +420 -0
- package/skill/assets/styles/swiss-console/sample/bullet.ts +122 -0
- package/skill/assets/styles/swiss-console/sample/content.ts +137 -0
- package/skill/assets/styles/swiss-console/sample/cta.ts +109 -0
- package/skill/assets/styles/swiss-console/sample/feature.ts +163 -0
- package/skill/assets/styles/swiss-console/sample/grid.ts +145 -0
- package/skill/assets/styles/swiss-console/sample/kinetic.ts +117 -0
- package/skill/assets/styles/swiss-console/sample/section.ts +127 -0
- package/skill/assets/styles/swiss-console/sample/stat.ts +148 -0
- package/skill/assets/styles/swiss-console/sample/title.ts +148 -0
- package/skill/assets/styles/swiss-console/sample/ui-showcase.ts +198 -0
- package/skill/assets/styles/swiss-console/tokens.css +39 -0
- package/skill/install/INSTALL.md +400 -0
- package/skill/references/audio/audio_plan.md +199 -0
- package/skill/references/audio/build.md +208 -0
- package/skill/references/audio/cue_template.md +219 -0
- package/skill/references/audio/ffmpeg_cookbook.md +267 -0
- package/skill/references/audio/music/music.md +171 -0
- package/skill/references/audio/music/providers/elevenlabs.md +170 -0
- package/skill/references/audio/music/providers/manual.md +140 -0
- package/skill/references/audio/music/providers/openverse.md +265 -0
- package/skill/references/audio/sfx/providers/elevenlabs.md +152 -0
- package/skill/references/audio/sfx/providers/manual.md +117 -0
- package/skill/references/audio/sfx/providers/openverse.md +243 -0
- package/skill/references/audio/sfx/sfx.md +149 -0
- package/skill/references/audio/styles.md +102 -0
- package/skill/references/audio/sync.md +237 -0
- package/skill/references/audio/voiceover/animation_sync.md +142 -0
- package/skill/references/audio/voiceover/provider_script.md +153 -0
- package/skill/references/audio/voiceover/providers/elevenlabs.md +288 -0
- package/skill/references/audio/voiceover/providers/manual.md +100 -0
- package/skill/references/audio/voiceover/script_writing.md +100 -0
- package/skill/references/audio/voiceover/style_intake.md +56 -0
- package/skill/references/audio/voiceover/sync_algorithm.md +167 -0
- package/skill/references/audio/voiceover.md +296 -0
- package/skill/references/audio.md +135 -0
- package/skill/references/authoring_segment.md +446 -0
- package/skill/references/create_or_edit_video.md +232 -0
- package/skill/references/dev_server.md +157 -0
- package/skill/references/export.md +145 -0
- package/skill/references/new_video.md +117 -0
- package/skill/references/project_structure.md +144 -0
- package/skill/references/setup.md +109 -0
- package/skill/references/setup_new_style.md +158 -0
- package/skill/references/styles.md +154 -0
- package/skill/references/testing.md +115 -0
- package/skill/references/types.md +240 -0
- package/src/cli/entry/components/copy_button.ts +42 -0
- package/src/cli/entry/components/download_modal.ts +204 -0
- package/src/cli/entry/components/empty_state.ts +55 -0
- package/src/cli/entry/components/hide_hud_tab.ts +37 -0
- package/src/cli/entry/components/icons.ts +31 -0
- package/src/cli/entry/components/top_bar.ts +69 -0
- package/src/cli/entry/components/video_card.ts +57 -0
- package/src/cli/entry/dev_frame.ts +189 -0
- package/src/cli/entry/entry_index.ts +16 -0
- package/src/cli/entry/entry_video.ts +24 -0
- package/src/cli/entry/index.html +12 -0
- package/src/cli/entry/parse_slug.ts +14 -0
- package/src/cli/entry/render.html +17 -0
- package/src/cli/entry/render_entry.ts +121 -0
- package/src/cli/entry/styles/base.css +45 -0
- package/src/cli/entry/styles/components.css +605 -0
- package/src/cli/entry/styles/tokens.css +44 -0
- package/src/cli/entry/video.html +22 -0
- package/src/cli/entry/views/homepage.ts +66 -0
- package/src/cli/entry/views/video_view.ts +286 -0
- 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
|