+
+
+
+ {tab.description}{' '}
+ Ctrl+S to save.
+
+
{saved && Saved ✓}
{error && {error}}
-
-
-
- Override the default runtime styles. This file is loaded after default.css in the runtime player.
- Press Ctrl+S to save.
-
-
-
{loading ? (
Loading…
@@ -84,14 +120,196 @@ export default function CustomCssPanel() {
)
}
+
+// ── CSS Reference ────────────────────────────────────────────────────────────
+
+const CSS_REFERENCE: { section: string; items: { selector: string; desc: string }[] }[] = [
+ {
+ section: 'Decision Buttons',
+ items: [
+ { selector: '#decision-overlay', desc: 'Full-screen overlay; use [data-position] for layout' },
+ { selector: '#decision-buttons', desc: 'Inner flex column holding buttons' },
+ { selector: '.decision-btn', desc: 'Each decision button' },
+ { selector: '.decision-btn.default', desc: 'Default choice (highlighted border)' },
+ { selector: '.decision-btn:hover', desc: 'Hovered state' },
+ ],
+ },
+ {
+ section: 'Subtitles',
+ items: [
+ { selector: '#subtitle-container', desc: 'Positioned wrapper (bottom-center by default)' },
+ { selector: '#subtitle-text', desc: 'Inline text span — font, color, bg, padding' },
+ { selector: '#subtitle-text:empty', desc: 'Hidden when no subtitle showing' },
+ ],
+ },
+ {
+ section: 'Video & Stage',
+ items: [
+ { selector: '#stage', desc: 'Video area container' },
+ { selector: '#video-el', desc: 'Main scene
diff --git a/editor/frontend/src/components/editor/TransitionEditor.tsx b/editor/frontend/src/components/editor/TransitionEditor.tsx
index 74943f5..c24d081 100644
--- a/editor/frontend/src/components/editor/TransitionEditor.tsx
+++ b/editor/frontend/src/components/editor/TransitionEditor.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import type { TransitionResponse, TransitionType, Asset, TransitionLayerData, TransitionAudioData } from '@/types'
-import { getTransition, setTransitionType, saveTransitionLayers, saveTransitionAudio } from '@/api/transition'
+import { getTransition, setTransitionType, saveTransitionLayers, saveTransitionAudio, setTransitionBackgroundColor } from '@/api/transition'
import type { VideoLayerRequest, AudioTrackRequest } from '@/api/nodeEditor'
import { listAssets } from '@/api/assets'
import { startTransitionPreview, type PreviewJobStatus } from '@/api/preview'
@@ -32,6 +32,7 @@ export default function TransitionEditor({ edgeId }: TransitionEditorProps) {
const [videoAssets, setVideoAssets] = useState
([])
const [audioAssets, setAudioAssets] = useState([])
const [durationInput, setDurationInput] = useState('')
+ const [bgColorInput, setBgColorInput] = useState('#ffffff')
const [previewJob, setPreviewJob] = useState(null)
const [previewing, setPreviewing] = useState(false)
@@ -57,6 +58,7 @@ export default function TransitionEditor({ edgeId }: TransitionEditorProps) {
.then(([d, v, a]) => {
setData(d)
setDurationInput(d.duration != null ? String(d.duration) : '')
+ setBgColorInput(d.backgroundColor ?? '#ffffff')
setVideoAssets(v)
setAudioAssets(a)
})
@@ -288,9 +290,40 @@ export default function TransitionEditor({ edgeId }: TransitionEditorProps) {
)}
{isVideo && (
-
- Video transition uses custom video layers and audio tracks — configure in the Layers and Audio tabs.
-
+ <>
+
+
+
+ Composited behind video layers with alpha channel.
+
+
+ setBgColorInput(e.target.value)}
+ onBlur={async () => {
+ const result = await withSave(() => setTransitionBackgroundColor(edgeId, bgColorInput))
+ if (result) setData(result)
+ }}
+ className="w-10 h-8 rounded cursor-pointer border border-border bg-transparent"
+ />
+ setBgColorInput(e.target.value)}
+ onBlur={async () => {
+ const result = await withSave(() => setTransitionBackgroundColor(edgeId, bgColorInput))
+ if (result) setData(result)
+ }}
+ className="input-base text-xs py-1 w-28 font-mono"
+ placeholder="#ffffff"
+ />
+
+
+
+ Video transition uses custom video layers and audio tracks — configure in the Layers and Audio tabs.
+
+ >
)}
>
)}
diff --git a/editor/frontend/src/types/index.ts b/editor/frontend/src/types/index.ts
index 677db17..f963f26 100644
--- a/editor/frontend/src/types/index.ts
+++ b/editor/frontend/src/types/index.ts
@@ -16,6 +16,7 @@ export interface GraphNode {
isRoot: boolean
isEnd: boolean
autoContinue: boolean
+ loopVideo: boolean
backgroundColor?: string
decisionAppearanceConfig?: DecisionAppearanceConfig
musicAssetId?: string | null
@@ -191,6 +192,7 @@ export interface TransitionResponse {
transitionAllowed: boolean
type: TransitionType | null
duration: number | null
+ backgroundColor: string | null
videoLayers: TransitionLayerData[]
audioTracks: TransitionAudioData[]
}
diff --git a/runtime/src/main/java/com/engine/runtime/RuntimeServer.java b/runtime/src/main/java/com/engine/runtime/RuntimeServer.java
index e613217..588b604 100644
--- a/runtime/src/main/java/com/engine/runtime/RuntimeServer.java
+++ b/runtime/src/main/java/com/engine/runtime/RuntimeServer.java
@@ -68,6 +68,7 @@ public void start() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
+ server.createContext("/api/game/info", this::handleInfo);
server.createContext("/api/game/state", this::handleState);
server.createContext("/api/game/decide", this::handleDecide);
server.createContext("/api/game/restart", this::handleRestart);
@@ -80,6 +81,19 @@ public void start() throws Exception {
server.start();
}
+ // ── GET /api/game/info ─────────────────────────────────────────────────────
+
+ private void handleInfo(HttpExchange ex) throws IOException {
+ if ("OPTIONS".equals(ex.getRequestMethod())) { RequestHelper.handleOptions(ex); return; }
+ if (!"GET".equals(ex.getRequestMethod())) { RequestHelper.sendError(ex, 405, "Method not allowed"); return; }
+
+ Map info = new LinkedHashMap<>();
+ String name = (manifest.project != null && manifest.project.name != null)
+ ? manifest.project.name : "Arvexis";
+ info.put("projectName", name);
+ RequestHelper.sendJson(ex, 200, info);
+ }
+
// ── GET /api/game/state ────────────────────────────────────────────────────
private void handleState(HttpExchange ex) throws IOException {
@@ -120,6 +134,7 @@ private void handleDecide(HttpExchange ex) throws IOException {
trans.put("edgeId", result.transEdge().id);
trans.put("type", result.transEdge().transition.type);
trans.put("duration", result.transEdge().transition.duration);
+ trans.put("backgroundColor", result.transEdge().transition.backgroundColor);
trans.put("transitionHlsUrl", "/hls/trans_" + result.transEdge().id + "/master.m3u8");
resp.put("transition", trans);
} else {
@@ -224,14 +239,14 @@ private void handleStatic(HttpExchange ex) throws IOException {
String path = ex.getRequestURI().getPath();
if ("/".equals(path) || path.isBlank()) path = "/index.html";
- // Serve custom.css from project dir (user-editable)
- if ("/custom.css".equals(path)) {
- Path customCss = projectDir.resolve("custom.css");
- if (Files.exists(customCss)) {
- byte[] bytes = Files.readAllBytes(customCss);
+ // Serve user-editable CSS files from project dir (custom.css, buttons.css, subtitles.css)
+ if ("/custom.css".equals(path) || "/buttons.css".equals(path) || "/subtitles.css".equals(path)) {
+ Path cssFile = projectDir.resolve(path.substring(1));
+ if (Files.exists(cssFile)) {
+ byte[] bytes = Files.readAllBytes(cssFile);
RequestHelper.sendBytes(ex, 200, "text/css; charset=UTF-8", bytes);
} else {
- // Return empty CSS if no custom file exists
+ // Return empty CSS if no file exists
RequestHelper.sendBytes(ex, 200, "text/css; charset=UTF-8", new byte[0]);
}
return;
@@ -269,6 +284,8 @@ private Map buildStateResponse(GameState s, String locale) {
// Scene-level auto-continue: only active when there are no explicit decisions
boolean autoContinues = engine.sceneAutoContinues(s.currentSceneId);
resp.put("autoContinue", autoContinues);
+ // Loop video flag
+ resp.put("loopVideo", scene != null && scene.loopVideo);
if (autoContinues) {
try {
GameEngine.TraversalResult tr = engine.peek(s, "CONTINUE");
diff --git a/runtime/src/main/java/com/engine/runtime/game/Manifest.java b/runtime/src/main/java/com/engine/runtime/game/Manifest.java
index a6d4460..1b98b84 100644
--- a/runtime/src/main/java/com/engine/runtime/game/Manifest.java
+++ b/runtime/src/main/java/com/engine/runtime/game/Manifest.java
@@ -19,6 +19,7 @@ public class Manifest {
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ProjectConfig {
+ @JsonProperty("name") public String name;
@JsonProperty("fps") public int fps;
@JsonProperty("decisionTimeoutSecs") public double decisionTimeoutSecs = 5.0;
@JsonProperty("defaultLocaleCode") public String defaultLocaleCode;
@@ -39,6 +40,7 @@ public static class NodeData {
@JsonProperty("conditions") public List conditions;
@JsonProperty("decisionAppearanceConfig") public String decisionAppearanceConfig; // raw JSON string
@JsonProperty("autoContinue") public boolean autoContinue;
+ @JsonProperty("loopVideo") public boolean loopVideo;
@JsonProperty("musicAssetId") public String musicAssetId;
@JsonProperty("musicAssetRelPath") public String musicAssetRelPath;
}
@@ -77,8 +79,9 @@ public static class EdgeData {
@JsonIgnoreProperties(ignoreUnknown = true)
public static class TransitionData {
- @JsonProperty("type") public String type;
- @JsonProperty("duration") public double duration;
+ @JsonProperty("type") public String type;
+ @JsonProperty("duration") public double duration;
+ @JsonProperty("backgroundColor") public String backgroundColor;
}
// ── Localization ──────────────────────────────────────────────────────────
diff --git a/runtime/src/main/resources/client/default.css b/runtime/src/main/resources/client/default.css
index bcdb93d..9fec186 100644
--- a/runtime/src/main/resources/client/default.css
+++ b/runtime/src/main/resources/client/default.css
@@ -4,19 +4,24 @@
* ============================================================================
*
* This file defines the default look and feel of the runtime player.
- * To customize, create a `custom.css` file in your project directory.
- * The custom.css is loaded AFTER this file, so your rules will override.
+ * To customize, use the editor's CSS panel which provides three separate files:
+ * - buttons.css — decision button overrides
+ * - subtitles.css — subtitle display overrides
+ * - custom.css — general / catch-all overrides
+ * Load order: default.css → buttons.css → subtitles.css → custom.css
+ * Later files override earlier ones.
*
* ── DOCUMENT STRUCTURE ──────────────────────────────────────────────────────
*
* body
* └─ #arvexis-root — outer wrapper (fullscreen)
* ├─ #main-menu — game main menu screen
- * │ ├─ .menu-title — game title text
- * │ └─ .menu-actions — button group
- * │ ├─ #btn-continue — Continue saved game
- * │ ├─ #btn-new-game — Start new game
- * │ └─ #btn-menu-settings — Open settings
+ * │ ├─ .menu-title #menu-title — game title (project name)
+ * │ ├─ .menu-actions — button group
+ * │ │ ├─ #btn-continue — Continue saved game
+ * │ │ ├─ #btn-new-game — Start new game
+ * │ │ └─ #btn-menu-settings — Open settings
+ * │ └─ .powered-by — "Powered by Arvexis" footer
* │
* ├─ #game-screen — active gameplay area (hidden in menu)
* │ ├─ #stage — video + overlays container
@@ -47,10 +52,11 @@
* │
* ├─ #pause-overlay — pause menu (covers game screen)
* │ ├─ .pause-title — "Paused" heading
- * │ └─ .pause-actions — button group
- * │ ├─ #btn-resume — Resume game
- * │ ├─ #btn-pause-settings — Open settings
- * │ └─ #btn-quit-menu — Quit to main menu
+ * │ ├─ .pause-actions — button group
+ * │ │ ├─ #btn-resume — Resume game
+ * │ │ ├─ #btn-pause-settings — Open settings
+ * │ │ └─ #btn-quit-menu — Quit to main menu
+ * │ └─ .powered-by — "Powered by Arvexis" footer
* │
* └─ #settings-overlay — settings panel (covers everything)
* ├─ .settings-title — "Settings" heading
@@ -176,6 +182,28 @@ body {
cursor: default;
}
+/* ── "Powered By" footer (main menu & pause) ────────────────────────────── */
+
+.powered-by {
+ position: absolute;
+ bottom: 24px;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.35);
+ letter-spacing: 0.02em;
+ pointer-events: auto;
+}
+.powered-by a {
+ color: rgba(255, 255, 255, 0.5);
+ text-decoration: none;
+ transition: color 0.2s;
+}
+.powered-by a:hover {
+ color: var(--arvexis-accent);
+ text-decoration: underline;
+}
+
/* ── Game Screen ─────────────────────────────────────────────────────────── */
#game-screen {
@@ -208,7 +236,7 @@ body {
transition: opacity 0.05s;
z-index: 2;
}
-#transition-el.active { opacity: 1; }
+#transition-el.active { opacity: 1; z-index: 4; }
#freeze-canvas {
position: absolute;
diff --git a/runtime/src/main/resources/client/game.js b/runtime/src/main/resources/client/game.js
index cf6b28c..cbf12a6 100644
--- a/runtime/src/main/resources/client/game.js
+++ b/runtime/src/main/resources/client/game.js
@@ -17,6 +17,7 @@ let preloadedHls = {}; // url → Hls (preloaded transitions)
let preloadedSceneHls = {}; // url → Hls (preloaded next-scene)
let currentMusicUrl = null; // currently playing music URL
let gamePaused = false;
+let loopHandler = null; // persistent 'ended' handler for manual video looping
// ── App state machine ────────────────────────────────────────────────────────
// Screens: 'menu' | 'game' | 'paused' | 'settings'
@@ -401,9 +402,20 @@ async function loadLocales() {
loadSettings();
applySettings();
showScreen('menu');
- await Promise.all([checkContinue(), loadLocales()]);
+ await Promise.all([checkContinue(), loadLocales(), loadProjectInfo()]);
})();
+async function loadProjectInfo() {
+ try {
+ const { projectName } = await apiFetch('/api/game/info');
+ if (projectName) {
+ const titleEl = $('menu-title');
+ if (titleEl) titleEl.textContent = projectName;
+ document.title = projectName + ' — Interactive Video';
+ }
+ } catch { /* fallback to default title */ }
+}
+
async function checkContinue() {
try {
const { hasSave } = await apiFetch('/api/game/has-save');
@@ -419,17 +431,30 @@ async function loadScene(state) {
currentState = state;
decisionMade = false;
+ // Clean up any persistent loop handler from the previous scene
+ if (loopHandler) { videoEl.removeEventListener('ended', loopHandler); loopHandler = null; }
+
hideDecisions();
hideCountdown();
stopSubtitleSync();
endScreen.classList.remove('visible');
- showSpinner('Loading scene…');
+ // Only show spinner when there is no freeze frame covering the screen (i.e. initial load)
+ if (freezeCanvas.style.display === 'none') showSpinner('Loading…');
// Destroy previous HLS
if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
- await loadHls(videoEl, state.sceneHlsUrl, (hls) => { hlsInstance = hls; });
+ // Use preloaded scene HLS if available (fast path for auto-continue scenes)
+ const sceneUrl = state.sceneHlsUrl;
+ // Destroy any preloaded scene HLS — its HTTP fetches already warmed the
+ // browser cache, but the instance can't be reliably reattached to a new element.
+ if (preloadedSceneHls[sceneUrl]) {
+ preloadedSceneHls[sceneUrl].destroy();
+ delete preloadedSceneHls[sceneUrl];
+ }
+
+ await loadHls(videoEl, sceneUrl, (hls) => { hlsInstance = hls; });
// Apply video volume from settings
videoEl.volume = settings.videoVolume;
@@ -448,19 +473,17 @@ async function loadScene(state) {
// Load subtitles for this scene
setSubtitles(state.subtitles || []);
- hideSpinner();
-
- videoEl.play().catch(() => {});
-
- // Start subtitle sync loop
- startSubtitleSync();
+ const loopVideo = !!state.loopVideo;
+ videoEl.loop = false; // always false; looping is handled manually so 'ended' always fires
const decisions = state.decisions || [];
const timeout = state.decisionTimeoutSecs || 5;
const isEnd = state.isEnd;
- // Scene-level auto-continue: no explicit decisions, flag set → play immediately on end
- if (state.autoContinue && decisions.length === 0) {
+ // ── Register all event handlers BEFORE play() to avoid race conditions ──
+ // (short videos can fire 'ended' before listeners are attached otherwise)
+
+ if (state.autoContinue) {
videoEl.addEventListener('ended', async () => {
captureFreeze();
if (!decisionMade) {
@@ -468,43 +491,73 @@ async function loadScene(state) {
await makeDecision('CONTINUE');
}
}, { once: true });
- return;
- }
-
- // ── Decision appearance timing ───────────────────────────────────────────
- let appearAt = null; // null = after video ends
- try {
- if (state.decisionAppearanceConfig) {
- const cfg = JSON.parse(state.decisionAppearanceConfig);
- if (cfg.timing === 'at_timestamp' && typeof cfg.timestamp === 'number') {
- appearAt = cfg.timestamp;
+ } else {
+ // ── Decision appearance timing ─────────────────────────────────────────
+ let appearAt = null; // null = after video ends
+ try {
+ if (state.decisionAppearanceConfig) {
+ const cfg = JSON.parse(state.decisionAppearanceConfig);
+ if (cfg.timing === 'at_timestamp' && typeof cfg.timestamp === 'number') {
+ appearAt = cfg.timestamp;
+ }
+ }
+ } catch {}
+
+ if (decisions.length > 0) {
+ if (appearAt !== null) {
+ videoEl.addEventListener('timeupdate', function onTimeUpdate() {
+ if (videoEl.currentTime >= appearAt) {
+ videoEl.removeEventListener('timeupdate', onTimeUpdate);
+ if (!decisionMade) showDecisions(decisions, timeout);
+ }
+ });
}
- }
- } catch {}
- if (decisions.length > 0) {
- if (appearAt !== null) {
- videoEl.addEventListener('timeupdate', function onTimeUpdate() {
- if (videoEl.currentTime >= appearAt) {
- videoEl.removeEventListener('timeupdate', onTimeUpdate);
+ if (loopVideo) {
+ // Looping scene: manually replay video each cycle so 'ended' keeps firing.
+ // Show decisions after the first play-through (or via timestamp), then keep looping.
+ let decisionsShown = false;
+ loopHandler = function onLoop() {
+ if (decisionMade) { videoEl.removeEventListener('ended', loopHandler); loopHandler = null; return; }
+ if (isEnd) { videoEl.removeEventListener('ended', loopHandler); loopHandler = null; captureFreeze(); showEndScreen(); return; }
+ if (!decisionsShown && appearAt === null) { decisionsShown = true; showDecisions(decisions, timeout); }
+ if (hlsInstance) hlsInstance.startLoad(0);
+ videoEl.currentTime = 0;
+ videoEl.play().catch(() => {});
+ };
+ videoEl.addEventListener('ended', loopHandler);
+ } else {
+ // Non-looping: freeze on last frame and show decisions
+ videoEl.addEventListener('ended', function onEnded() {
+ videoEl.removeEventListener('ended', onEnded);
+ captureFreeze();
+ if (isEnd) { showEndScreen(); return; }
if (!decisionMade) showDecisions(decisions, timeout);
- }
- });
+ }, { once: true });
+ }
+ } else if (isEnd) {
+ videoEl.addEventListener('ended', () => { captureFreeze(); showEndScreen(); }, { once: true });
+ } else {
+ videoEl.addEventListener('ended', async () => {
+ captureFreeze();
+ await makeDecision('CONTINUE');
+ }, { once: true });
}
- videoEl.addEventListener('ended', function onEnded() {
- videoEl.removeEventListener('ended', onEnded);
- captureFreeze();
- if (isEnd) { showEndScreen(); return; }
- if (!decisionMade) showDecisions(decisions, timeout);
- }, { once: true });
- } else if (isEnd) {
- videoEl.addEventListener('ended', () => { captureFreeze(); showEndScreen(); }, { once: true });
- } else {
- videoEl.addEventListener('ended', async () => {
- captureFreeze();
- await makeDecision('CONTINUE');
- }, { once: true });
}
+
+ // Start playback and wait for first frame to render before revealing
+ videoEl.play().catch(() => {});
+ await new Promise(resolve => {
+ if (videoEl.readyState >= 3) { resolve(); return; }
+ videoEl.addEventListener('playing', resolve, { once: true });
+ setTimeout(resolve, 1000);
+ });
+
+ hideSpinner();
+ hideFreeze();
+
+ // Start subtitle sync loop
+ startSubtitleSync();
}
// ── HLS loading helper ────────────────────────────────────────────────────────
@@ -521,16 +574,20 @@ function loadHls(videoElement, src, onReady) {
attachNative();
} else {
const hls = new Hls({ enableWorker: false, lowLatencyMode: false });
+ let done = false;
+ const finish = () => { if (!done) { done = true; resolve(); } };
hls.loadSource(src);
hls.attachMedia(videoElement);
- hls.on(Hls.Events.MANIFEST_PARSED, () => { onReady && onReady(hls); resolve(); });
+ hls.on(Hls.Events.MANIFEST_PARSED, () => { onReady && onReady(hls); });
+ hls.on(Hls.Events.FRAG_BUFFERED, finish);
+ videoElement.addEventListener('canplay', finish, { once: true });
hls.on(Hls.Events.ERROR, (_, data) => {
if (data.fatal) {
hls.destroy();
- reject(new Error('HLS fatal error: ' + data.type));
+ if (!done) { done = true; reject(new Error('HLS fatal error: ' + data.type)); }
}
});
- onReady && onReady(hls);
+ setTimeout(finish, 10000);
}
});
}
@@ -662,7 +719,9 @@ function hideFreeze() {
// ── Decision handling ─────────────────────────────────────────────────────────
async function makeDecision(decisionKey) {
- showSpinner('Deciding…');
+ captureFreeze();
+ videoEl.loop = false; // stop looping immediately on decision
+ videoEl.pause();
stopSubtitleSync();
try {
const result = await apiFetch('/api/game/decide' + localeQueryString(), { method: 'POST',
@@ -672,7 +731,6 @@ async function makeDecision(decisionKey) {
await playTransition(result.transition);
}
- hideFreeze();
await loadScene(result.nextState);
} catch (e) {
@@ -683,35 +741,40 @@ async function makeDecision(decisionKey) {
// ── Transition playback ───────────────────────────────────────────────────────
async function playTransition(trans) {
- showSpinner('Transition…');
-
if (transHls) { transHls.destroy(); transHls = null; }
const url = trans.transitionHlsUrl;
+ // Destroy any preloaded HLS for this URL — its HTTP fetches already warmed the
+ // browser cache, but the HLS instance can't be reliably reattached to a new element.
if (preloadedHls[url]) {
- transHls = preloadedHls[url];
+ preloadedHls[url].destroy();
delete preloadedHls[url];
- transHls.attachMedia(transEl);
}
+ // Load fresh (fast due to browser-cached segments)
await loadHls(transEl, url, (hls) => { transHls = hls; });
+ hideSpinner();
+ // Apply background colour behind the transition video (for alpha-channel / transparent transitions)
+ transEl.style.backgroundColor = trans.backgroundColor || '';
+ // transition-el.active has z-index 4 (above freeze-canvas z-index 3) so no need to hide freeze first
transEl.classList.add('active');
transEl.play().catch(() => {});
return new Promise(resolve => {
- transEl.addEventListener('ended', () => {
+ let cleaned = false;
+ function cleanup() {
+ if (cleaned) return;
+ cleaned = true;
transEl.classList.remove('active');
- transEl.src = '';
+ transEl.style.backgroundColor = '';
if (transHls) { transHls.destroy(); transHls = null; }
resolve();
- }, { once: true });
-
- setTimeout(() => {
- transEl.classList.remove('active');
- resolve();
- }, ((trans.duration || 2) + 1) * 1000);
+ }
+ transEl.addEventListener('ended', cleanup, { once: true });
+ // Fallback: if 'ended' never fires, clean up after duration + buffer
+ setTimeout(cleanup, ((trans.duration || 2) + 1) * 1000);
});
}
diff --git a/runtime/src/main/resources/client/index.html b/runtime/src/main/resources/client/index.html
index 2ca6630..54cd25d 100644
--- a/runtime/src/main/resources/client/index.html
+++ b/runtime/src/main/resources/client/index.html
@@ -5,6 +5,8 @@
Arvexis — Interactive Video
+
+
@@ -15,12 +17,13 @@
MAIN MENU — shown on launch; game-like entry point
══════════════════════════════════════════════════════════════════════ -->