Browse Source

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.
refactor/sync-centralize
RUSHIL AMBARISH KADU 6 months ago
parent
commit
10c52d318a
  1. 6
      steps/src/debug.js
  2. 159
      steps/src/fileLoader.js
  3. 37
      steps/src/modal.js
  4. 3
      steps/src/state.js

6
steps/src/debug.js

@ -12,4 +12,10 @@ export const debugFlags = {
// Logs related to file loading, parsing, and caching // Logs related to file loading, parsing, and caching
fileLoading: false, 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
}; };

159
steps/src/fileLoader.js

@ -82,6 +82,9 @@ async function processFilePipeline(jsonFile, videoFile, fromCache) {
if (videoFile) { if (videoFile) {
appState.videoFilename = videoFile.name; appState.videoFilename = videoFile.name;
localStorage.setItem("videoFilename", appState.videoFilename); localStorage.setItem("videoFilename", appState.videoFilename);
// CRITICAL FIX: Reset the videoMissing flag when a new video is being loaded.
appState.videoMissing = false;
if (!fromCache) { if (!fromCache) {
const savePromise = saveFileWithMetadata("video", videoFile).catch((e) => const savePromise = saveFileWithMetadata("video", videoFile).catch((e) =>
console.warn(`Non-blocking cache save failed for Video:`, 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) --- // --- PART E: Load Video (if new) ---
if (videoFile) { if (videoFile) {
await loadVideo(videoFile);
const videoLoaded = await loadVideo(videoFile);
if (!videoLoaded) {
appState.videoMissing = true;
}
} }
// --- PART F: Finalize UI --- // --- PART F: Finalize UI ---
finalizeSetup(); 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. // Log the results of the non-blocking cache operations once they complete.
if (cachePromises.length > 0) { 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 // 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() { function finalizeSetup() {
@ -218,6 +273,12 @@ function finalizeSetup() {
canvasPlaceholder.style.display = "none"; canvasPlaceholder.style.display = "none";
featureToggles.classList.remove("hidden"); featureToggles.classList.remove("hidden");
} else { } 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? // 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. // Current behavior: keep placeholder until JSON loads.
} }
@ -248,7 +309,7 @@ function finalizeSetup() {
// Update speed graph with new data + video duration // Update speed graph with new data + video duration
// Note: videoPlayer.duration might be NaN if video isn't loaded. // 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.setData(appState.vizData, duration);
appState.speedGraphInstance.redraw(); appState.speedGraphInstance.redraw();
} }

37
steps/src/modal.js

@ -11,10 +11,16 @@ import {
} from "./dom.js"; } from "./dom.js";
let modalResolve = null; let modalResolve = null;
export function showModal(message, isConfirm = false) {
export function showModal(
message,
isConfirm = false,
buttonLabels = { ok: "OK", cancel: "Cancel" }
) {
return new Promise((resolve) => { return new Promise((resolve) => {
modalText.textContent = message; 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); modalCancelBtn.classList.toggle("hidden", !isConfirm);
// --- THIS IS THE FIX --- // --- THIS IS THE FIX ---
@ -57,18 +63,21 @@ export function updateLoadingModal(percent, message) {
} }
// The hideModal function now also resets the progress bar // 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 // Event listeners remain the same

3
steps/src/state.js

@ -4,6 +4,9 @@ export const appState = {
zoomCountdownInterval: null, // The interval timer for the countdown zoomCountdownInterval: null, // The interval timer for the countdown
fps: 0, // To store the calculated FPS for performance monitoring fps: 0, // To store the calculated FPS for performance monitoring
isRawOnlyMode: false, // <-- ADD THIS LINE 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.) // Stores the parsed visualization data (radar frames, tracks, etc.)
vizData: null, vizData: null,

Loading…
Cancel
Save