diff --git a/steps/src/debug.js b/steps/src/debug.js index 4d9aa3c..4ca815c 100644 --- a/steps/src/debug.js +++ b/steps/src/debug.js @@ -12,4 +12,10 @@ export const debugFlags = { // Logs related to file loading, parsing, and caching fileLoading: false, + + // If true, file caching blocks the main thread for debugging. + CACHE_BLOCKING: false, + + VIDEO_LOAD_TIMEOUT: 10000, // 10 seconds + VIDEO_LOAD_RETRIES: 1, // Number of retries if loading fails }; \ No newline at end of file diff --git a/steps/src/fileLoader.js b/steps/src/fileLoader.js index 6ec6db5..4866527 100644 --- a/steps/src/fileLoader.js +++ b/steps/src/fileLoader.js @@ -82,6 +82,9 @@ async function processFilePipeline(jsonFile, videoFile, fromCache) { if (videoFile) { appState.videoFilename = videoFile.name; localStorage.setItem("videoFilename", appState.videoFilename); + // CRITICAL FIX: Reset the videoMissing flag when a new video is being loaded. + appState.videoMissing = false; + if (!fromCache) { const savePromise = saveFileWithMetadata("video", videoFile).catch((e) => console.warn(`Non-blocking cache save failed for Video:`, e) @@ -145,15 +148,21 @@ async function processFilePipeline(jsonFile, videoFile, fromCache) { // --- PART E: Load Video (if new) --- if (videoFile) { - await loadVideo(videoFile); + const videoLoaded = await loadVideo(videoFile); + if (!videoLoaded) { + appState.videoMissing = true; + } } // --- PART F: Finalize UI --- finalizeSetup(); - // Hide modal - updateLoadingModal(100, "Complete!"); - setTimeout(hideModal, 300); + // Hide modal only if the video didn't fail. If it failed, the video + // loader has already handled showing an error/choice modal. + if (!appState.videoMissing) { + updateLoadingModal(100, "Complete!"); + setTimeout(hideModal, 300); + } // Log the results of the non-blocking cache operations once they complete. if (cachePromises.length > 0) { @@ -165,50 +174,96 @@ async function processFilePipeline(jsonFile, videoFile, fromCache) { // Encapsulates the specific logic for loading a video file into the player -function loadVideo(file) { - return new Promise((resolve, reject) => { - const fileURL = URL.createObjectURL(file); - - // Setup cleanup to remove listeners - const cleanup = () => { - clearInterval(spinnerInterval); - videoPlayer.removeEventListener("loadedmetadata", onMetadataLoaded); - videoPlayer.removeEventListener("canplaythrough", onCanPlayThrough); - videoPlayer.removeEventListener("error", onError); - }; - - const onMetadataLoaded = () => { - updateLoadingModal(95, "Finalizing visualization..."); - }; - - const onCanPlayThrough = () => { - cleanup(); - resolve(); - }; - - const onError = (e) => { - console.error("Video loading error:", e); - cleanup(); - reject(e); - }; - - // Attach listeners - videoPlayer.addEventListener("loadedmetadata", onMetadataLoaded, { once: true }); - videoPlayer.addEventListener("canplaythrough", onCanPlayThrough, { once: true }); - videoPlayer.addEventListener("error", onError, { once: true }); - - // Spinner - const spinnerChars = ["|", "/", "-", "\\"]; - let spinnerIndex = 0; - const spinnerInterval = setInterval(() => { - const spinnerText = spinnerChars[spinnerIndex % spinnerChars.length]; - updateLoadingModal(85, `Loading video ${spinnerText}`); - spinnerIndex++; - }, 150); - - // Apply source - setupVideoPlayer(fileURL); - }); +let retries = 0; +function loadVideo(file, isRetry = false) { + return new Promise(async (resolve) => { + let metadataLoaded = false; + let loadTimeout; + + const fileURL = isRetry ? videoPlayer.src : URL.createObjectURL(file); + + const cleanup = () => { + clearTimeout(loadTimeout); + videoPlayer.removeEventListener("loadedmetadata", onMetadataLoaded); + videoPlayer.removeEventListener("canplaythrough", onCanPlayThrough); + videoPlayer.removeEventListener("error", onError); + }; + + const onMetadataLoaded = () => { + metadataLoaded = true; + updateLoadingModal(95, "Finalizing visualization..."); + }; + + const onCanPlayThrough = () => { + cleanup(); + resolve(true); + }; + + const handleTimeout = async () => { + if (metadataLoaded) { + console.warn( + "Video 'canplaythrough' event timed out, but 'loadedmetadata' fired. Proceeding with playback." + ); + appState.videoReadyByFallback = true; + cleanup(); + resolve(true); + } else { + // Neither event fired, video is likely broken. + await hideModal(); // Hide the loading modal first and wait for it to finish. + const choice = await showModal( + "Video is taking too long to load. It might be corrupted.", + true, // isConfirm + { ok: "Retry", cancel: "Continue without Video" } + ); + + if (choice) { // Retry + if (retries < debugFlags.VIDEO_LOAD_RETRIES) { + retries++; + console.log(`Retrying video load... (Attempt ${retries})`); + showLoadingModal(`Retrying video load...`); + videoPlayer.load(); // Tell the video element to re-fetch + resolve(loadVideo(file, true)); // Recurse + } else { + await showModal("Video load failed after multiple retries."); + resolve(false); // Failed to load + } + } else { // Continue without video + console.warn("User opted to continue without video."); + cleanup(); + // Revoke URL to free memory if we're giving up on it + if (videoPlayer.src.startsWith('blob:')) { + URL.revokeObjectURL(videoPlayer.src); + } + videoPlayer.src = ""; + videoPlayer.classList.add("hidden"); + videoPlaceholder.classList.remove("hidden"); + resolve(false); // Signal that video is not loaded + } + } + }; + + const onError = async (e) => { + console.error("Video loading error:", e); + cleanup(); + await hideModal(); // Await is CRITICAL to prevent a race condition with the next modal. + showModal("Error loading video file. It may be an unsupported format or corrupted."); + resolve(false); + }; + + // Attach listeners + videoPlayer.addEventListener("loadedmetadata", onMetadataLoaded, { once: true }); + videoPlayer.addEventListener("canplaythrough", onCanPlayThrough, { once: true }); + videoPlayer.addEventListener("error", onError, { once: true }); + + // Start the timeout + loadTimeout = setTimeout(handleTimeout, debugFlags.VIDEO_LOAD_TIMEOUT); + + // Apply source only if it's not a retry + if (!isRetry) { + retries = 0; // Reset retry counter for new files + setupVideoPlayer(fileURL); + } + }); } function finalizeSetup() { @@ -218,6 +273,12 @@ function finalizeSetup() { canvasPlaceholder.style.display = "none"; featureToggles.classList.remove("hidden"); } else { + // If there's no viz data (e.g., video-only load), hide the canvas + canvasPlaceholder.style.display = ""; // Show placeholder + featureToggles.classList.add("hidden"); + if (appState.p5_instance) { + appState.p5_instance.noLoop(); + } // If we don't have data yet (video only), we might keep the placeholder or show an empty canvas? // Current behavior: keep placeholder until JSON loads. } @@ -248,7 +309,7 @@ function finalizeSetup() { // Update speed graph with new data + video duration // Note: videoPlayer.duration might be NaN if video isn't loaded. - const duration = videoPlayer.duration || 0; + const duration = appState.videoMissing ? 0 : (videoPlayer.duration || 0); appState.speedGraphInstance.setData(appState.vizData, duration); appState.speedGraphInstance.redraw(); } diff --git a/steps/src/modal.js b/steps/src/modal.js index f773000..4b50ded 100644 --- a/steps/src/modal.js +++ b/steps/src/modal.js @@ -11,10 +11,16 @@ import { } from "./dom.js"; let modalResolve = null; -export function showModal(message, isConfirm = false) { +export function showModal( + message, + isConfirm = false, + buttonLabels = { ok: "OK", cancel: "Cancel" } +) { return new Promise((resolve) => { modalText.textContent = message; - // This line correctly shows the "Cancel" button only when needed. + modalOkBtn.textContent = buttonLabels.ok || "OK"; + modalCancelBtn.textContent = buttonLabels.cancel || "Cancel"; + modalCancelBtn.classList.toggle("hidden", !isConfirm); // --- THIS IS THE FIX --- @@ -57,18 +63,21 @@ export function updateLoadingModal(percent, message) { } // The hideModal function now also resets the progress bar -export function hideModal(value) { - modalOverlay.classList.add("opacity-0"); - modalContent.classList.add("scale-95"); - setTimeout(() => { - modalContainer.classList.add("hidden"); - if (modalProgressContainer && modalProgressBar && modalProgressText) { - modalProgressContainer.classList.add("hidden"); - modalProgressBar.style.width = "0%"; - modalProgressText.textContent = ""; - } - if (modalResolve) modalResolve(value); - }, 200); +export function hideModal(value) { // This now returns a promise + return new Promise(resolve => { + modalOverlay.classList.add("opacity-0"); + modalContent.classList.add("scale-95"); + setTimeout(() => { + modalContainer.classList.add("hidden"); + if (modalProgressContainer && modalProgressBar && modalProgressText) { + modalProgressContainer.classList.add("hidden"); + modalProgressBar.style.width = "0%"; + modalProgressText.textContent = ""; + } + if (modalResolve) modalResolve(value); + resolve(); // Resolve the promise returned by hideModal itself + }, 200); + }); } // Event listeners remain the same diff --git a/steps/src/state.js b/steps/src/state.js index d4464bc..a480d9e 100644 --- a/steps/src/state.js +++ b/steps/src/state.js @@ -4,6 +4,9 @@ export const appState = { zoomCountdownInterval: null, // The interval timer for the countdown fps: 0, // To store the calculated FPS for performance monitoring isRawOnlyMode: false, // <-- ADD THIS LINE + videoReadyByFallback: false, // True if video resolved via loadedmetadata timeout + videoMissing: false, // True if user opts to continue without a video + // Stores the parsed visualization data (radar frames, tracks, etc.) vizData: null,