From 10c52d318a93588633bc8fd1b63b9e8b5d49078e Mon Sep 17 00:00:00 2001 From: rakadu1 Date: Thu, 27 Nov 2025 11:05:55 +0530 Subject: [PATCH] feat(loading): Implement robust video loading and non-blocking cache This commit introduces significant improvements to the file loading pipeline to enhance stability and user experience. The application startup is no longer blocked by file caching, and it now gracefully handles corrupted or slow-loading video files instead of hanging. Key Changes: - **Non-Blocking File Caching:** - Caching files to IndexedDB is now a non-blocking, "fire-and-forget" operation. - `saveFileWithMetadata` calls are no longer awaited, allowing the UI and parsing to proceed immediately. - `Promise.allSettled` is used to log cache results in the background. - **Robust Video Loading:** - Implemented a 10-second timeout for the video `canplaythrough` event to prevent indefinite hangs. - If the timeout occurs but `loadedmetadata` has fired, the video is treated as playable, allowing the application to continue. - If the video fails to load entirely (due to a timeout or an `error` event), the user is presented with a modal to either "Retry" or "Continue without Video". - **Bug Fixes:** - Fixed a critical state bug where `appState.videoMissing` was not reset on a new file load, which caused issues when re-uploading a valid video after a failure. - Resolved multiple race conditions in the modal system where error and choice modals would close prematurely after being displayed. - Replaced the blocking `alert()` on storage quota errors with a non-blocking modal, ensuring the app remains responsive. --- steps/src/debug.js | 6 ++ steps/src/fileLoader.js | 159 +++++++++++++++++++++++++++------------- steps/src/modal.js | 37 ++++++---- steps/src/state.js | 3 + 4 files changed, 142 insertions(+), 63 deletions(-) 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,