var googleUser = null; var videoInfo; var currentRange = 1; let knownTransitions = []; let thumbnailTemplates = {}; let globalPageState = 0; const CHAPTER_MARKER_DELIMITER = "\n==========\n"; const CHAPTER_MARKER_DELIMITER_PARTIAL = "=========="; const PAGE_STATE = { CLEAN: 0, DIRTY: 1, SUBMITTING: 2, CONFIRMING: 3, }; // References to Jcrop "stages" for the advanced thumbnail editor crop tool let videoFrameStage; let templateStage; window.addEventListener("DOMContentLoaded", async (event) => { commonPageSetup(); globalLoadChatWorker.onmessage = (event) => { updateChatDataFromWorkerResponse(event.data); renderChatLog(); }; window.addEventListener("beforeunload", handleLeavePage); const timeUpdateForm = document.getElementById("stream-time-settings"); timeUpdateForm.addEventListener("submit", async (event) => { event.preventDefault(); if (!videoInfo) { addError( "Time updates are ignored before the video metadata has been retrieved from Wubloader.", ); return; } const newStartField = document.getElementById("stream-time-setting-start"); const newStart = dateTimeFromBusTime(newStartField.value); if (!newStart) { addError("Failed to parse start time"); return; } const newEndField = document.getElementById("stream-time-setting-end"); let newEnd = null; if (newEndField.value !== "") { newEnd = dateTimeFromBusTime(newEndField.value); if (!newEnd) { addError("Failed to parse end time"); return; } } const oldStart = getStartTime(); const startAdjustment = newStart.diff(oldStart).as("seconds"); let newDuration = newEnd === null ? Infinity : newEnd.diff(newStart).as("seconds"); // The video duration isn't precisely the video times, but can be padded by up to the // segment length on either side. const segmentList = getSegmentList(); newDuration += segmentList[0].duration; newDuration += segmentList[segmentList.length - 1].duration; // Abort for ranges that exceed new times const rangeDefinitionsElements = document.getElementById("range-definitions").children; for (const rangeContainer of rangeDefinitionsElements) { const rangeStartField = rangeContainer.getElementsByClassName("range-definition-start")[0]; const rangeEndField = rangeContainer.getElementsByClassName("range-definition-end")[0]; const rangeStart = videoPlayerTimeFromVideoHumanTime(rangeStartField.value); const rangeEnd = videoPlayerTimeFromVideoHumanTime(rangeEndField.value); if (rangeStart !== null && rangeStart < startAdjustment) { addError("The specified video load time excludes part of an edited clip range."); return; } if (rangeEnd !== null && rangeEnd + startAdjustment > newDuration) { addError("The specified video load time excludes part of an edited clip range."); return; } } const rangesData = []; for (const rangeContainer of rangeDefinitionsElements) { const rangeStartField = rangeContainer.getElementsByClassName("range-definition-start")[0]; const rangeEndField = rangeContainer.getElementsByClassName("range-definition-end")[0]; const rangeStartTimeString = rangeStartField.value; const rangeEndTimeString = rangeEndField.value; const rangeStartTime = dateTimeFromVideoHumanTime(rangeStartTimeString); const rangeEndTime = dateTimeFromVideoHumanTime(rangeEndTimeString); rangesData.push({ start: rangeStartTime, end: rangeEndTime }); } const videoElement = document.getElementById("video"); const currentVideoPosition = dateTimeFromVideoPlayerTime(videoElement.currentTime); globalStartTimeString = wubloaderTimeFromDateTime(newStart); globalEndTimeString = wubloaderTimeFromDateTime(newEnd); updateSegmentPlaylist(); globalPlayer.once(Hls.Events.LEVEL_LOADED, (_data) => { const newVideoPosition = videoPlayerTimeFromDateTime(currentVideoPosition); if (newVideoPosition !== null) { videoElement.currentTime = newVideoPosition; } let rangeErrorCount = 0; for (const [rangeIndex, rangeData] of rangesData.entries()) { const rangeContainer = rangeDefinitionsElements[rangeIndex]; const rangeStartField = rangeContainer.getElementsByClassName("range-definition-start")[0]; const rangeEndField = rangeContainer.getElementsByClassName("range-definition-end")[0]; if (rangeData.start) { rangeStartField.value = videoHumanTimeFromDateTime(rangeData.start); } else { rangeErrorCount++; } if (rangeData.end) { rangeEndField.value = videoHumanTimeFromDateTime(rangeData.end); } else { rangeErrorCount++; } } if (rangeErrorCount > 0) { addError( "Some ranges couldn't be updated for the new video time endpoints. Please verify the time range values.", ); } rangeDataUpdated(); }); const waveformImage = document.getElementById("waveform"); if (newEnd === null) { waveformImage.classList.add("hidden"); } else { updateWaveform(); waveformImage.classList.remove("hidden"); } }); loadTransitions(); // Intentionally not awaiting, fire and forget await loadVideoInfo(); document.getElementById("stream-time-setting-start-pad").addEventListener("click", (_event) => { const startTimeField = document.getElementById("stream-time-setting-start"); let startTime = startTimeField.value; startTime = dateTimeFromBusTime(startTime); startTime = startTime.minus({ minutes: 1 }); startTimeField.value = busTimeFromDateTime(startTime); }); document.getElementById("stream-time-setting-end-pad").addEventListener("click", (_event) => { const endTimeField = document.getElementById("stream-time-setting-end"); let endTime = endTimeField.value; endTime = dateTimeFromBusTime(endTime); endTime = endTime.plus({ minutes: 1 }); endTimeField.value = busTimeFromDateTime(endTime); }); const addRangeIcon = document.getElementById("add-range-definition"); if (canEditVideo()) { addRangeIcon.addEventListener("click", (_event) => { addRangeDefinition(); handleFieldChange(event); }); addRangeIcon.addEventListener("keypress", (event) => { if (event.key === "Enter") { addRangeDefinition(); handleFieldChange(event); } }); } else { addRangeIcon.classList.add("hidden"); } const enableChaptersElem = document.getElementById("enable-chapter-markers"); enableChaptersElem.addEventListener("change", (event) => { changeEnableChaptersHandler(); handleFieldChange(event); }); if (canEditVideo()) { for (const rangeStartSet of document.getElementsByClassName("range-definition-set-start")) { rangeStartSet.addEventListener("click", getRangeSetClickHandler("start")); } for (const rangeEndSet of document.getElementsByClassName("range-definition-set-end")) { rangeEndSet.addEventListener("click", getRangeSetClickHandler("end")); } } for (const rangeStartPlay of document.getElementsByClassName("range-definition-play-start")) { rangeStartPlay.addEventListener("click", rangePlayFromStartHandler); } for (const rangeEndPlay of document.getElementsByClassName("range-definition-play-end")) { rangeEndPlay.addEventListener("click", rangePlayFromEndHandler); } for (const rangeStart of document.getElementsByClassName("range-definition-start")) { rangeStart.addEventListener("change", (event) => { rangeDataUpdated(); handleFieldChange(event); }); } for (const rangeEnd of document.getElementsByClassName("range-definition-end")) { rangeEnd.addEventListener("change", (event) => { rangeDataUpdated(); handleFieldChange(event); }); } if (canEditMetadata()) { for (const addChapterMarker of document.getElementsByClassName( "add-range-definition-chapter-marker", )) { addChapterMarker.addEventListener("click", addChapterMarkerHandler); } } document.getElementById("range-definition-chapter-marker-first-description").addEventListener("input", (event) => { validateChapterDescription(event.target); }); document.getElementById("video-info-title").addEventListener("input", (event) => { validateVideoTitle(); document.getElementById("video-info-title-abbreviated").innerText = videoInfo.title_prefix + document.getElementById("video-info-title").value; handleFieldChange(event); }); document.getElementById("video-info-description").addEventListener("input", (event) => { validateVideoDescription(); handleFieldChange(event); }); document .getElementById("video-info-thumbnail-template") .addEventListener("change", thumbnailTemplateChanged); document .getElementById("video-info-thumbnail-mode") .addEventListener("change", updateThumbnailInputState); document .getElementById("video-info-thumbnail-time") .addEventListener("change", handleFieldChange); if (canEditMetadata()) { document.getElementById("video-info-thumbnail-time-set").addEventListener("click", (_event) => { const field = document.getElementById("video-info-thumbnail-time"); const videoPlayer = document.getElementById("video"); const videoPlayerTime = videoPlayer.currentTime; field.value = videoHumanTimeFromVideoPlayerTime(videoPlayerTime); }); document .getElementById("video-info-thumbnail-time-play") .addEventListener("click", (_event) => { const field = document.getElementById("video-info-thumbnail-time"); const thumbnailTime = videoPlayerTimeFromVideoHumanTime(field.value); if (thumbnailTime === null) { addError("Couldn't play from thumbnail frame; failed to parse time"); return; } const videoPlayer = document.getElementById("video"); videoPlayer.currentTime = thumbnailTime; }); } document .getElementById("video-info-thumbnail-template-source-image-update") .addEventListener("click", async (_event) => { const videoFrameImageElement = document.getElementById("video-info-thumbnail-template-video-source-image"); const timeEntryElement = document.getElementById("video-info-thumbnail-time"); const imageTime = wubloaderTimeFromVideoHumanTime(timeEntryElement.value); if (imageTime === null) { videoFrameImageElement.classList.add("hidden"); addError("Couldn't preview thumbnail; couldn't parse thumbnail frame timestamp"); return; } const imageTemplate = document.getElementById("video-info-thumbnail-template").value; const videoFrameQuery = new URLSearchParams({ timestamp: imageTime, }); videoFrameImageElement.src = `/frame/${globalStreamName}/source.png?${videoFrameQuery}`; videoFrameImageElement.classList.remove("hidden"); const templateImageElement = document.getElementById("video-info-thumbnail-template-overlay-image"); templateImageElement.src = `/thrimshim/template/${imageTemplate}.png`; templateImageElement.classList.remove("hidden"); const aspectRatioControls = document.getElementById("video-info-thumbnail-aspect-ratio-controls"); aspectRatioControls.classList.remove("hidden"); createTemplateCropWidgets(); }); document.getElementById("video-info-thumbnail-crop-0").addEventListener("input", updateTemplateCropWidgets); document.getElementById("video-info-thumbnail-crop-1").addEventListener("input", updateTemplateCropWidgets); document.getElementById("video-info-thumbnail-crop-2").addEventListener("input", updateTemplateCropWidgets); document.getElementById("video-info-thumbnail-crop-3").addEventListener("input", updateTemplateCropWidgets); document.getElementById("video-info-thumbnail-location-0").addEventListener("input", updateTemplateCropWidgets); document.getElementById("video-info-thumbnail-location-1").addEventListener("input", updateTemplateCropWidgets); document.getElementById("video-info-thumbnail-location-2").addEventListener("input", updateTemplateCropWidgets); document.getElementById("video-info-thumbnail-location-3").addEventListener("input", updateTemplateCropWidgets); document.getElementById("video-info-thumbnail-lock-aspect-ratio").addEventListener("change", updateTemplateCropAspectRatio); document.getElementById("video-info-thumbnail-aspect-ratio-match-right").addEventListener("click", function(){ // Calculate and copy the aspect ratio from the video field to the template const videoFieldX1 = document.getElementById("video-info-thumbnail-crop-0"); const videoFieldY1 = document.getElementById("video-info-thumbnail-crop-1"); const videoFieldX2 = document.getElementById("video-info-thumbnail-crop-2"); const videoFieldY2 = document.getElementById("video-info-thumbnail-crop-3"); const videoFieldAspectRatio = (videoFieldX2.value-videoFieldX1.value)/(videoFieldY2.value-videoFieldY1.value); templateStage.setOptions({aspectRatio: videoFieldAspectRatio}); // Re-apply the locked/unlocked status updateTemplateCropAspectRatio(); }); document.getElementById("video-info-thumbnail-aspect-ratio-match-left").addEventListener("click", function(){ // Calculate and copy the aspect ratio from the template to the video field const templateFieldX1 = document.getElementById("video-info-thumbnail-location-0"); const templateFieldY1 = document.getElementById("video-info-thumbnail-location-1"); const templateFieldX2 = document.getElementById("video-info-thumbnail-location-2"); const templateFieldY2 = document.getElementById("video-info-thumbnail-location-3"); const templateFieldAspectRatio = (templateFieldX2.value-templateFieldX1.value)/(templateFieldY2.value-templateFieldY1.value); videoFrameStage.setOptions({aspectRatio: templateFieldAspectRatio}); // Re-apply the locked/unlocked status updateTemplateCropAspectRatio(); }); document .getElementById("video-info-thumbnail-template-preview-generate") .addEventListener("click", async (_event) => { const imageElement = document.getElementById("video-info-thumbnail-template-preview-image"); const thumbnailMode = document.getElementById("video-info-thumbnail-mode").value; if (thumbnailMode === "ONEOFF") { try { const data = await renderThumbnail(); imageElement.src = `data:image/png;base64,${data}`; } catch (e) { imageElement.classList.add("hidden"); addError(`${e}`); return; } } else { const timeEntryElement = document.getElementById("video-info-thumbnail-time"); const imageTime = wubloaderTimeFromVideoHumanTime(timeEntryElement.value); if (imageTime === null) { imageElement.classList.add("hidden"); addError("Couldn't preview thumbnail; couldn't parse thumbnail frame timestamp"); return; } const imageTemplate = document.getElementById("video-info-thumbnail-template").value; const [crop, loc] = getTemplatePosition(); const query = new URLSearchParams({ timestamp: imageTime, template: imageTemplate, crop: crop.join(","), location: loc.join(","), }); imageElement.src = `/thumbnail/${globalStreamName}/source.png?${query}`; } imageElement.classList.remove("hidden"); }); const thumbnailTemplateSelection = document.getElementById("video-info-thumbnail-template"); const thumbnailTemplatesListResponse = await fetch("/thrimshim/templates"); if (thumbnailTemplatesListResponse.ok) { const thumbnailTemplatesList = await thumbnailTemplatesListResponse.json(); const templateNames = thumbnailTemplatesList.map(t => t.name); templateNames.sort(); for (const template of thumbnailTemplatesList) { thumbnailTemplates[template.name] = template; } for (const templateName of templateNames) { const templateOption = document.createElement("option"); templateOption.innerText = templateName; templateOption.value = templateName; templateOption.title = thumbnailTemplates[templateName].description; if (templateName === videoInfo.thumbnail_template) { templateOption.selected = true; } thumbnailTemplateSelection.appendChild(templateOption); } thumbnailTemplateChanged(); } else { addError("Failed to load thumbnail templates list"); } if (videoInfo.thumbnail_crop !== null) { for (let i = 0; i < 4; i++) { document.getElementById(`video-info-thumbnail-crop-${i}`).value = videoInfo.thumbnail_crop[i]; } } if (videoInfo.thumbnail_location !== null) { for (let i = 0; i < 4; i++) { document.getElementById(`video-info-thumbnail-location-${i}`).value = videoInfo.thumbnail_location[i]; } } document.getElementById("video-info-thumbnail-mode").value = videoInfo.thumbnail_mode; updateThumbnailInputState(); if (videoInfo.thumbnail_time) { document.getElementById("video").addEventListener("loadedmetadata", (_event) => { document.getElementById("video-info-thumbnail-time").value = videoHumanTimeFromWubloaderTime( videoInfo.thumbnail_time, ); }); } // Ensure that changing values on load doesn't set keep the page dirty. globalPageState = PAGE_STATE.CLEAN; document.getElementById("submit-button").addEventListener("click", (_event) => { submitVideo(); }); document.getElementById("save-button").addEventListener("click", (_event) => { saveVideoDraft(); }); document.getElementById("submit-changes-button").addEventListener("click", (_event) => { submitVideoChanges(); }); document.getElementById("advanced-submission").addEventListener("click", (_event) => { const advancedOptionsContainer = document.getElementById("advanced-submission-options"); advancedOptionsContainer.classList.toggle("hidden"); }); document .getElementById("advanced-submission-option-allow-holes") .addEventListener("change", () => { updateDownloadLink(); }); document.getElementById("download-type-select").addEventListener("change", () => { updateDownloadLink(); }); document.getElementById("download-frame").addEventListener("click", (_event) => { downloadFrame(); }); document.getElementById("manual-link-update").addEventListener("click", (_event) => { const manualLinkDataContainer = document.getElementById("data-correction-manual-link"); manualLinkDataContainer.classList.toggle("hidden"); }); document .getElementById("data-correction-manual-link-submit") .addEventListener("click", (_event) => { setManualVideoLink(); }); document.getElementById("cancel-video-upload").addEventListener("click", (_event) => { cancelVideoUpload(); }); document.getElementById("reset-entire-video").addEventListener("click", (_event) => { const forceResetConfirmationContainer = document.getElementById( "data-correction-force-reset-confirm", ); forceResetConfirmationContainer.classList.remove("hidden"); }); document.getElementById("data-correction-force-reset-yes").addEventListener("click", (_event) => { resetVideoRow(); }); document.getElementById("data-correction-force-reset-no").addEventListener("click", (_event) => { const forceResetConfirmationContainer = document.getElementById( "data-correction-force-reset-confirm", ); forceResetConfirmationContainer.classList.add("hidden"); }); document.getElementById("google-auth-sign-out").addEventListener("click", (_event) => { googleSignOut(); }); }); async function loadTransitions() { const response = await fetch("/thrimshim/transitions"); if (!response.ok) { addError("Failed to fetch possible transition types. This probably means the wubloader host is down."); return; } knownTransitions = await response.json(); updateTransitionTypes(); } // Update the given list of transition type