You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wubloader/thrimbletrimmer/scripts/edit.js

1893 lines
65 KiB
JavaScript

var googleUser = null;
var videoInfo;
var currentRange = 1;
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,
};
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");
}
});
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("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-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-preview-generate")
.addEventListener("click", (_event) => {
const imageElement = document.getElementById("video-info-thumbnail-template-preview-image");
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;
imageElement.src = `/thumbnail/${globalStreamName}/source.png?timestamp=${imageTime}&template=${imageTemplate}`;
imageElement.classList.remove("hidden");
});
const thumbnailTemplateSelection = document.getElementById("video-info-thumbnail-template");
const thumbnailTemplatesListResponse = await fetch("/thumbnail-templates");
if (thumbnailTemplatesListResponse.ok) {
const thumbnailTemplatesList = await thumbnailTemplatesListResponse.json();
thumbnailTemplatesList.sort();
for (const templateName of thumbnailTemplatesList) {
const templateOption = document.createElement("option");
templateOption.innerText = templateName;
templateOption.value = templateName;
if (templateName === videoInfo.thumbnail_template) {
templateOption.selected = true;
}
thumbnailTemplateSelection.appendChild(templateOption);
}
} else {
addError("Failed to load thumbnail templates list");
}
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 loadVideoInfo() {
const queryParams = new URLSearchParams(window.location.search);
if (!queryParams.has("id")) {
addError("No video ID specified. Failed to load video data.");
return;
}
const videoID = queryParams.get("id");
const dataResponse = await fetch("/thrimshim/" + videoID);
if (!dataResponse.ok) {
addError(
"Failed to load video data. This probably means that the URL is out of date (video ID changed) or that everything is broken (or that the Wubloader host is down).",
);
return;
}
videoInfo = await dataResponse.json();
await initializeVideoInfo();
}
async function initializeVideoInfo() {
globalStreamName = videoInfo.video_channel;
globalBusStartTime = DateTime.fromISO(videoInfo.bustime_start);
let eventStartTime = dateTimeFromWubloaderTime(videoInfo.event_start);
let eventEndTime = videoInfo.event_end ? dateTimeFromWubloaderTime(videoInfo.event_end) : null;
// To account for various things (stream delay, just slightly off logging, etc.), we pad the start time by one minute
eventStartTime = eventStartTime.minus({ minutes: 1 });
// To account for various things (stream delay, just slightly off logging, etc.), we pad the end time by one minute.
// To account for the fact that we don't record seconds, but the event could've ended any time in the recorded minute, we pad by an additional minute.
if (eventEndTime) {
eventEndTime = eventEndTime.plus({ minutes: 2 });
}
globalStartTimeString = wubloaderTimeFromDateTime(eventStartTime);
if (eventEndTime) {
globalEndTimeString = wubloaderTimeFromDateTime(eventEndTime);
} else {
document.getElementById("waveform").classList.add("hidden");
}
// If a video was previously edited to points outside the event range, we should expand the loaded video to include the edited range
if (videoInfo.video_ranges && videoInfo.video_ranges.length > 0) {
let earliestStartTime = null;
let latestEndTime = null;
for (const range of videoInfo.video_ranges) {
let startTime = range[0];
let endTime = range[1];
if (startTime) {
startTime = dateTimeFromWubloaderTime(startTime);
} else {
startTime = null;
}
if (endTime) {
endTime = dateTimeFromWubloaderTime(endTime);
} else {
endTime = null;
}
if (!earliestStartTime || (startTime && startTime.diff(earliestStartTime).milliseconds < 0)) {
earliestStartTime = startTime;
}
if (!latestEndTime || (endTime && endTime.diff(latestEndTime).milliseconds > 0)) {
latestEndTime = endTime;
}
}
if (earliestStartTime && earliestStartTime.diff(eventStartTime).milliseconds < 0) {
earliestStartTime = earliestStartTime.minus({ minutes: 1 });
globalStartTimeString = wubloaderTimeFromDateTime(earliestStartTime);
}
if (latestEndTime && eventEndTime && latestEndTime.diff(eventEndTime).milliseconds > 0) {
// If we're getting the time from a previous draft edit, we have seconds, so one minute is enough
latestEndTime = latestEndTime.plus({ minutes: 1 });
globalEndTimeString = wubloaderTimeFromDateTime(latestEndTime);
}
}
document.getElementById("stream-time-setting-stream").innerText = globalStreamName;
document.getElementById("stream-time-setting-start").value =
busTimeFromWubloaderTime(globalStartTimeString);
document.getElementById("stream-time-setting-end").value =
busTimeFromWubloaderTime(globalEndTimeString);
updateWaveform();
const titlePrefixElem = document.getElementById("video-info-title-prefix");
titlePrefixElem.innerText = videoInfo.title_prefix;
const titleElem = document.getElementById("video-info-title");
if (videoInfo.video_title) {
titleElem.value = videoInfo.video_title;
} else {
titleElem.value = videoInfo.description;
}
validateVideoTitle();
document.getElementById("video-info-title-abbreviated").innerText =
videoInfo.title_prefix + titleElem.value;
const descriptionElem = document.getElementById("video-info-description");
if (videoInfo.video_description) {
descriptionElem.value = videoInfo.video_description;
} else {
descriptionElem.value = videoInfo.description;
}
validateVideoDescription();
const tagsElem = document.getElementById("video-info-tags");
if (videoInfo.video_tags) {
tagsElem.value = videoInfo.video_tags.join(",");
} else {
tagsElem.value = videoInfo.tags.join(",");
}
if (videoInfo.notes) {
const notesTextElem = document.getElementById("video-info-editor-notes");
notesTextElem.innerText = videoInfo.notes;
const notesContainer = document.getElementById("video-info-editor-notes-container");
notesContainer.classList.remove("hidden");
}
let modifiedAdvancedOptions = false;
if (videoInfo.allow_holes) {
const allowHolesCheckbox = document.getElementById("advanced-submission-option-allow-holes");
allowHolesCheckbox.checked = true;
modifiedAdvancedOptions = true;
}
const unlistedCheckbox = document.getElementById("advanced-submission-option-unlisted");
unlistedCheckbox.checked = !videoInfo.public;
if (!videoInfo.public) {
modifiedAdvancedOptions = true;
}
const uploadLocationSelection = document.getElementById(
"advanced-submission-option-upload-location",
);
for (locationName of videoInfo.upload_locations) {
const option = document.createElement("option");
option.value = locationName;
option.innerText = locationName;
if (videoInfo.upload_location === locationName) {
option.selected = true;
}
uploadLocationSelection.appendChild(option);
}
if (uploadLocationSelection.options.selectedIndex > 0) {
modifiedAdvancedOptions = true;
}
if (videoInfo.uploader_whitelist) {
modifiedAdvancedOptions = true;
const uploaderAllowlistBox = document.getElementById(
"advanced-submission-option-uploader-allow",
);
uploaderAllowlistBox.value = videoInfo.uploader_whitelist.join(",");
}
if (!canEditVideo()) {
if (canEditMetadata()) {
const submitButton = document.getElementById("submit-button");
submitButton.classList.add("hidden");
const saveButton = document.getElementById("save-button");
saveButton.classList.add("hidden");
const submitChangesButton = document.getElementById("submit-changes-button");
submitChangesButton.classList.remove("hidden");
document.getElementById("add-range-definition").classList.add("hidden");
const startTimes = document.getElementsByClassName("range-definition-start");
const endTimes = document.getElementsByClassName("range-definition-end");
for (const timeField of startTimes) {
timeField.disabled = true;
}
for (const timeField of endTimes) {
timeField.disabled = true;
}
for (const editIcon of document.getElementsByClassName("range-definition-set-start")) {
editIcon.classList.add("hidden");
}
for (const editIcon of document.getElementsByClassName("range-definition-set-end")) {
editIcon.classList.add("hidden");
}
} else {
for (const input of document.getElementsByTagName("input")) {
if (!isNonVideoInput(input)) {
input.disabled = true;
}
}
for (const textArea of document.getElementsByTagName("textarea")) {
if (!isNonVideoInput(textArea)) {
textArea.disabled = true;
}
}
for (const button of document.getElementsByTagName("button")) {
if (!isNonVideoInput(button)) {
button.disabled = true;
}
}
for (const selectBox of document.getElementsByTagName("select")) {
if (!isNonVideoInput(selectBox)) {
selectBox.disabled = true;
}
}
}
}
if (modifiedAdvancedOptions) {
const advancedSubmissionContainer = document.getElementById("advanced-submission-options");
advancedSubmissionContainer.classList.remove("hidden");
}
await loadVideoPlayerFromDefaultPlaylist();
const videoElement = document.getElementById("video");
const handleInitialSetupForDuration = (_event) => {
const rangeDefinitionsContainer = document.getElementById("range-definitions");
if (videoInfo.video_ranges && videoInfo.video_ranges.length > 0) {
const chapterData = [];
const descriptionField = document.getElementById("video-info-description");
let description = descriptionField.value;
if (description.indexOf(CHAPTER_MARKER_DELIMITER) !== -1) {
enableChapterMarkers(true);
const descriptionParts = description.split(CHAPTER_MARKER_DELIMITER, 2);
description = descriptionParts[0];
const chapterLines = descriptionParts[1].split("\n");
for (const chapterLine of chapterLines) {
const chapterLineData = chapterLine.split(" - ");
const chapterTime = unformatChapterTime(chapterLineData.shift());
const chapterDescription = chapterLineData.join(" - ");
chapterData.push({ start: chapterTime, description: chapterDescription });
}
}
let currentChapterIndex = 0;
let canAddChapters = true;
let rangeStartOffset = 0;
for (let rangeIndex = 0; rangeIndex < videoInfo.video_ranges.length; rangeIndex++) {
if (rangeIndex >= rangeDefinitionsContainer.children.length) {
addRangeDefinition();
}
const startWubloaderTime = videoInfo.video_ranges[rangeIndex][0];
const endWubloaderTime = videoInfo.video_ranges[rangeIndex][1];
const startPlayerTime = videoPlayerTimeFromWubloaderTime(startWubloaderTime);
const endPlayerTime = videoPlayerTimeFromWubloaderTime(endWubloaderTime);
if (startWubloaderTime) {
const startField =
rangeDefinitionsContainer.children[rangeIndex].getElementsByClassName(
"range-definition-start",
)[0];
startField.value = videoHumanTimeFromVideoPlayerTime(startPlayerTime);
}
if (endWubloaderTime) {
const endField =
rangeDefinitionsContainer.children[rangeIndex].getElementsByClassName(
"range-definition-end",
)[0];
endField.value = videoHumanTimeFromVideoPlayerTime(endPlayerTime);
}
const rangeDuration = endPlayerTime - startPlayerTime;
const rangeEndVideoTime = rangeStartOffset + rangeDuration;
if (canAddChapters && startWubloaderTime && endWubloaderTime) {
const chapterContainer = rangeDefinitionsContainer.children[
rangeIndex
].getElementsByClassName("range-definition-chapter-markers")[0];
if (currentChapterIndex === 0) {
const chapterStartField = document.getElementById(
"range-definition-chapter-marker-first-start",
);
const chapterDescField = document.getElementById(
"range-definition-chapter-marker-first-description",
);
let chapterStartValue = 0;
let chapterDescValue = "";
if (chapterData.length > 0) {
chapterStartValue = chapterData[0].start;
chapterDescValue = chapterData[0].description;
}
chapterStartField.value = videoHumanTimeFromVideoPlayerTime(
chapterStartValue - rangeStartOffset + startPlayerTime,
);
chapterDescField.value = chapterDescValue;
currentChapterIndex++;
}
while (
currentChapterIndex < chapterData.length &&
chapterData[currentChapterIndex].start < rangeEndVideoTime
) {
const chapterMarker = chapterMarkerDefinitionDOM();
const chapterStartField = chapterMarker.getElementsByClassName(
"range-definition-chapter-marker-start",
)[0];
chapterStartField.value = videoHumanTimeFromVideoPlayerTime(
chapterData[currentChapterIndex].start - rangeStartOffset + startPlayerTime,
);
const chapterDescField = chapterMarker.getElementsByClassName(
"range-definition-chapter-marker-description",
)[0];
chapterDescField.value = chapterData[currentChapterIndex].description;
chapterContainer.appendChild(chapterMarker);
currentChapterIndex++;
}
} else {
canAddChapters = false;
}
rangeStartOffset = rangeEndVideoTime;
}
if (canAddChapters) {
descriptionField.value = description;
validateVideoDescription();
}
} else {
const rangeStartField =
rangeDefinitionsContainer.getElementsByClassName("range-definition-start")[0];
rangeStartField.value = videoHumanTimeFromWubloaderTime(globalStartTimeString);
if (globalEndTimeString) {
const rangeEndField =
rangeDefinitionsContainer.getElementsByClassName("range-definition-end")[0];
rangeEndField.value = videoHumanTimeFromWubloaderTime(globalEndTimeString);
}
}
const firstChapterPlayFromStartTime = document.getElementById(
"range-definition-chapter-marker-first-play-start",
);
if (canEditMetadata()) {
firstChapterPlayFromStartTime.addEventListener("click", chapterMarkerPlayStartTimeHandler);
} else {
firstChapterPlayFromStartTime.classList.add("hidden");
}
rangeDataUpdated();
videoElement.removeEventListener("loadedmetadata", handleInitialSetupForDuration);
};
videoElement.addEventListener("loadedmetadata", handleInitialSetupForDuration);
videoElement.addEventListener("durationchange", (_event) => {
// Every time this is updated, we need to update based on the new video duration
rangeDataUpdated();
});
videoElement.addEventListener("timeupdate", (_event) => {
const timePercent = (videoElement.currentTime / videoElement.duration) * 100;
document.getElementById("waveform-marker").style.left = `${timePercent}%`;
});
// Ensure that changes made to fields during initial load don't affect the state
globalPageState = PAGE_STATE.CLEAN;
}
function updateWaveform() {
let waveformURL =
"/waveform/" + globalStreamName + "/" + videoInfo.video_quality + ".png?size=1920x125&";
const queryStringParts = startAndEndTimeQueryStringParts();
waveformURL += queryStringParts.join("&");
const waveformElem = document.getElementById("waveform");
waveformElem.src = waveformURL;
}
function googleOnSignIn(googleUserData) {
googleUser = googleUserData;
const signInElem = document.getElementById("google-auth-sign-in");
const signOutElem = document.getElementById("google-auth-sign-out");
signInElem.classList.add("hidden");
signOutElem.classList.remove("hidden");
}
async function googleSignOut() {
if (googleUser) {
googleUser = null;
await gapi.auth2.getAuthInstance().signOut();
const signInElem = document.getElementById("google-auth-sign-in");
const signOutElem = document.getElementById("google-auth-sign-out");
signInElem.classList.remove("hidden");
signOutElem.classList.add("hidden");
}
}
function updateThumbnailInputState(event) {
handleFieldChange(event);
const newValue = document.getElementById("video-info-thumbnail-mode").value;
const unhideIDs = [];
if (newValue === "BARE") {
unhideIDs.push("video-info-thumbnail-time-options");
} else if (newValue === "TEMPLATE") {
unhideIDs.push("video-info-thumbnail-template-options");
unhideIDs.push("video-info-thumbnail-time-options");
unhideIDs.push("video-info-thumbnail-template-preview");
} else if (newValue === "CUSTOM") {
unhideIDs.push("video-info-thumbnail-custom-options");
}
for (const optionElement of document.getElementsByClassName(
"video-info-thumbnail-mode-options",
)) {
optionElement.classList.add("hidden");
}
for (elemID of unhideIDs) {
document.getElementById(elemID).classList.remove("hidden");
}
}
function getStartTime() {
if (!globalStartTimeString) {
return null;
}
return dateTimeFromWubloaderTime(globalStartTimeString);
}
function getEndTime() {
if (!globalEndTimeString) {
return null;
}
return dateTimeFromWubloaderTime(globalEndTimeString);
}
function validateVideoTitle() {
const videoTitleField = document.getElementById("video-info-title");
const videoTitle = videoTitleField.value;
if (videoTitle.length > videoInfo.title_max_length) {
videoTitleField.classList.add("input-error");
videoTitleField.title = "Title is too long";
} else if (videoTitle.indexOf("<") !== -1 || videoTitle.indexOf(">") !== -1) {
videoTitleField.classList.add("input-error");
videoTitleField.title = "Title contains invalid characters";
} else {
videoTitleField.classList.remove("input-error");
videoTitleField.title = "";
}
}
function validateVideoDescription() {
const videoDescField = document.getElementById("video-info-description");
const videoDesc = videoDescField.value;
if (videoDesc.length > 5000) {
videoDescField.classList.add("input-error");
videoDescField.title = "Description is too long";
} else if (videoDesc.indexOf("<") !== -1 || videoDesc.indexOf(">") !== -1) {
videoDescField.classList.add("input-error");
videoDescField.title = "Description contains invalid characters";
} else if (videoDesc.indexOf(CHAPTER_MARKER_DELIMITER) !== -1) {
videoDescField.classList.add("input-error");
videoDescField.title = "Description contains a manual chapter marker";
} else {
videoDescField.classList.remove("input-error");
videoDescField.title = "";
}
}
function validateChapterDescription(chapterDescField) {
const chapterDesc = chapterDescField.value;
if (chapterDesc.indexOf("<") !== -1 || chapterDesc.indexOf(">") !== -1) {
chapterDescField.classList.add("input-error");
chapterDescField.title = "Chapter description may not contain angle brackets (< or >)";
} else if (Array.from(chapterDesc).some(c => c.charCodeAt(0) > 127)) { // any char is non-ascii
// We don't know what chars are safe outside the ascii range, so we just warn on any of them.
// We know emoji are not safe.
chapterDescField.classList.add("input-error");
chapterDescField.title = "Chapter descriptions with non-ascii characters may cause issues; proceed with caution";
} else {
chapterDescField.classList.remove("input-error");
chapterDescField.title = "";
}
}
async function submitVideo() {
return sendVideoData("EDITED", false);
}
async function saveVideoDraft() {
return sendVideoData("UNEDITED", false);
}
async function submitVideoChanges() {
return sendVideoData("MODIFIED", false);
}
async function sendVideoData(newState, overrideChanges) {
let videoDescription = document.getElementById("video-info-description").value;
if (videoDescription.indexOf(CHAPTER_MARKER_DELIMITER_PARTIAL) !== -1) {
addError(
"Couldn't submit edits: Description contains manually entered chapter marker delimiter",
);
return;
}
const edited = newState === "EDITED";
const submissionResponseElem = document.getElementById("submission-response");
submissionResponseElem.classList.value = ["submission-response-pending"];
submissionResponseElem.innerText = "Submitting video...";
const rangesData = [];
let chaptersData = [];
const chaptersEnabled = document.getElementById("enable-chapter-markers").checked;
let rangeStartInFinalVideo = 0;
for (const rangeContainer of document.getElementById("range-definitions").children) {
const rangeStartHuman =
rangeContainer.getElementsByClassName("range-definition-start")[0].value;
const rangeEndHuman = rangeContainer.getElementsByClassName("range-definition-end")[0].value;
const rangeStartPlayer = videoPlayerTimeFromVideoHumanTime(rangeStartHuman);
const rangeEndPlayer = videoPlayerTimeFromVideoHumanTime(rangeEndHuman);
const rangeStartSubmit = wubloaderTimeFromVideoPlayerTime(rangeStartPlayer);
const rangeEndSubmit = wubloaderTimeFromVideoPlayerTime(rangeEndPlayer);
if (edited && (!rangeStartSubmit || !rangeEndSubmit)) {
submissionResponseElem.classList.value = ["submission-response-error"];
let errorMessage;
if (!rangeStartSubmit && !rangeEndSubmit) {
errorMessage = `The range endpoints "${rangeStartSubmit}" and "${rangeEndSubmit}" are not valid.`;
} else if (!rangeStartSubmit) {
errorMessage = `The range endpoint "${rangeStartSubmit} is not valid.`;
} else {
errorMessage = `The range endpoint "${rangeEndSubmit}" is not valid.`;
}
submissionResponseElem.innerText = errorMessage;
return;
}
if (edited && rangeEndPlayer < rangeStartPlayer) {
submissionResponseElem.innerText =
"One or more ranges has an end time prior to its start time.";
submissionResponseElem.classList.value = ["submission-response-error"];
return;
}
rangesData.push({
start: rangeStartSubmit,
end: rangeEndSubmit,
});
if (chaptersEnabled && rangeStartSubmit && rangeEndSubmit) {
const rangeChapters = [];
for (const chapterContainer of rangeContainer.getElementsByClassName(
"range-definition-chapter-markers",
)[0].children) {
const startField = chapterContainer.getElementsByClassName(
"range-definition-chapter-marker-start",
)[0];
const descField = chapterContainer.getElementsByClassName(
"range-definition-chapter-marker-description",
)[0];
const startFieldTime = videoPlayerTimeFromVideoHumanTime(startField.value);
if (startFieldTime === null) {
if (edited) {
submissionResponseElem.innerText = `Unable to parse chapter start time: ${startField.value}`;
submissionResponseElem.classList.value = ["submission-response-error"];
return;
}
continue;
}
if (startFieldTime < rangeStartPlayer || startFieldTime > rangeEndPlayer) {
submissionResponseElem.innerText = `The chapter at "${startField.value}" is outside its containing time range.`;
submissionResponseElem.classList.value = ["submission-response-error"];
return;
}
const chapterStartTime = rangeStartInFinalVideo + startFieldTime - rangeStartPlayer;
const chapterData = {
start: chapterStartTime,
videoStart: startFieldTime,
description: descField.value,
};
rangeChapters.push(chapterData);
}
rangeChapters.sort((a, b) => a.videoStart - b.videoStart);
chaptersData = chaptersData.concat(rangeChapters);
} else {
const enableChaptersElem = document.getElementById("enable-chapter-markers");
if (
enableChaptersElem.checked &&
rangeContainer.getElementsByClassName("range-definition-chapter-marker-start").length > 0
) {
submissionResponseElem.classList.value = ["submission-response-error"];
submissionResponseElem.innerText =
"Chapter markers can't be saved for ranges without valid endpoints.";
return;
}
}
rangeStartInFinalVideo += rangeEndPlayer - rangeStartPlayer;
}
const finalVideoDuration = rangeStartInFinalVideo;
const videoHasHours = finalVideoDuration >= 3600;
const ranges = [];
const transitions = [];
for (const range of rangesData) {
ranges.push([range.start, range.end]);
// In the future, handle transitions
transitions.push(null);
}
// The first range will never have a transition defined, so remove that one
transitions.shift();
if (chaptersData.length > 0) {
if (chaptersData[0].start !== 0) {
submissionResponseElem.innerText =
"The first chapter must start at the beginning of the video";
submissionResponseElem.classList.value = ["submission-response-error"];
return;
}
let lastChapterStart = 0;
for (let chapterIndex = 1; chapterIndex < chaptersData.length; chapterIndex++) {
if (edited && chaptersData[chapterIndex].start - lastChapterStart < 10) {
submissionResponseElem.innerText = "Chapters must be at least 10 seconds apart";
submissionResponseElem.classList.value = ["submission-response-error"];
return;
}
lastChapterStart = chaptersData[chapterIndex].start;
}
const chapterTextList = [];
for (const chapterData of chaptersData) {
const startTime = formatChapterTime(chapterData.start, videoHasHours);
chapterTextList.push(`${startTime} - ${chapterData.description}`);
}
videoDescription = videoDescription + CHAPTER_MARKER_DELIMITER + chapterTextList.join("\n");
}
const thumbnailMode = document.getElementById("video-info-thumbnail-mode").value;
let thumbnailTemplate = null;
let thumbnailTime = null;
let thumbnailImage = null;
if (thumbnailMode === "BARE" || thumbnailMode === "TEMPLATE") {
thumbnailTime = wubloaderTimeFromVideoHumanTime(
document.getElementById("video-info-thumbnail-time").value,
);
if (thumbnailTime === null) {
submissionResponseElem.innerText = "The thumbnail time is invalid";
submissionResponseElem.classList.value = ["submission-response-error"];
return;
}
}
if (thumbnailMode === "TEMPLATE") {
thumbnailTemplate = document.getElementById("video-info-thumbnail-template").value;
}
if (thumbnailMode === "CUSTOM") {
const fileInput = document.getElementById("video-info-thumbnail-custom");
if (fileInput.files.length === 0) {
if (!videoInfo.thumbnail_image) {
submissionResponseElem.innerText =
"A thumbnail file was not provided for the custom thumbnail";
submissionResponseElem.classList.value = ["submission-response-error"];
return;
}
thumbnailImage = videoInfo.thumbnail_image;
} else {
const fileHandle = fileInput.files[0];
const fileReader = new FileReader();
let loadPromiseResolve;
const loadPromise = new Promise((resolve, _reject) => {
loadPromiseResolve = resolve;
});
fileReader.addEventListener("loadend", (event) => {
loadPromiseResolve();
});
fileReader.readAsDataURL(fileHandle);
await loadPromise;
const fileLoadData = fileReader.result;
if (fileLoadData.error) {
submissionResponseElem.innerText = `An error (${fileLoadData.error.name}) occurred loading the custom thumbnail: ${fileLoadData.error.message}`;
submissionResponseElem.classList.value = ["submission-response-error"];
return;
}
if (fileLoadData.substring(0, 22) !== "data:image/png;base64,") {
submissionResponseElem.innerHTML =
"An error occurred converting the uploaded image to base64.";
submissionResponseElem.classList.value = ["submission-response-error"];
return;
}
thumbnailImage = fileLoadData.substring(22);
}
}
const videoTitle = document.getElementById("video-info-title").value;
const videoTags = document.getElementById("video-info-tags").value.split(",");
const allowHoles = document.getElementById("advanced-submission-option-allow-holes").checked;
const isPublic = !document.getElementById("advanced-submission-option-unlisted").checked;
const uploadLocation = document.getElementById(
"advanced-submission-option-upload-location",
).value;
const uploaderAllowlistValue = document.getElementById(
"advanced-submission-option-uploader-allow",
).value;
const uploaderAllowlist = uploaderAllowlistValue ? uploaderAllowlistValue.split(",") : null;
const editData = {
video_ranges: ranges,
video_transitions: transitions,
video_title: videoTitle,
video_description: videoDescription,
video_tags: videoTags,
allow_holes: allowHoles,
upload_location: uploadLocation,
public: isPublic,
video_channel: globalStreamName,
video_quality: videoInfo.video_quality,
uploader_whitelist: uploaderAllowlist,
state: newState,
thumbnail_mode: thumbnailMode,
thumbnail_template: thumbnailTemplate,
thumbnail_time: thumbnailTime,
thumbnail_image: thumbnailImage,
// We also provide some sheet column values to verify data hasn't changed.
sheet_name: videoInfo.sheet_name,
event_start: videoInfo.event_start,
event_end: videoInfo.event_end,
category: videoInfo.category,
description: videoInfo.description,
notes: videoInfo.notes,
tags: videoInfo.tags,
};
if (googleUser) {
editData.token = googleUser.getAuthResponse().id_token;
}
if (overrideChanges) {
editData.override_changes = true;
}
globalPageState = PAGE_STATE.SUBMITTING;
const submitResponse = await fetch(`/thrimshim/${videoInfo.id}`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(editData),
});
if (submitResponse.ok) {
globalPageState = PAGE_STATE.CLEAN;
submissionResponseElem.classList.value = ["submission-response-success"];
if (newState === "EDITED") {
submissionResponseElem.innerText = "Submitted edit";
const submissionTimesListContainer = document.createElement("ul");
for (const range of rangesData) {
const submissionTimeResponse = document.createElement("li");
const rangeStartWubloader = range.start;
const rangeStartVideoHuman = videoHumanTimeFromWubloaderTime(rangeStartWubloader);
const rangeEndWubloader = range.end;
const rangeEndVideoHuman = videoHumanTimeFromWubloaderTime(rangeEndWubloader);
submissionTimeResponse.innerText = `from ${rangeStartVideoHuman} (${rangeStartWubloader}) to ${rangeEndVideoHuman} (${rangeEndWubloader})`;
submissionTimesListContainer.appendChild(submissionTimeResponse);
}
submissionResponseElem.appendChild(submissionTimesListContainer);
} else if (newState === "UNEDITED") {
submissionResponseElem.innerText = "Saved draft";
} else if (newState === "MODIFIED") {
submissionResponseElem.innerText = "Submitted changes";
} else {
// should never happen but shrug
submissionResponseElem.innerText = `Submitted state ${newState}`;
}
} else {
globalPageState = PAGE_STATE.DIRTY;
submissionResponseElem.classList.value = ["submission-response-error"];
if (submitResponse.status === 409) {
globalPageState = PAGE_STATE.CONFIRMING;
const serverErrorNode = document.createTextNode(await submitResponse.text());
const submitButton = document.createElement("button");
if (newState === "UNEDITED") {
submitButton.innerText = "Save Draft Anyway";
} else if (newState === "MODIFIED") {
submitButton.innerText = "Submit Changes Anyway";
} else {
submitButton.innerText = "Submit Anyway";
}
submitButton.addEventListener("click", (_event) => {
sendVideoData(newState, true);
});
submissionResponseElem.innerHTML = "";
submissionResponseElem.appendChild(serverErrorNode);
submissionResponseElem.appendChild(submitButton);
} else if (submitResponse.status === 401) {
submissionResponseElem.innerText = "Unauthorized. Did you remember to sign in?";
} else {
submissionResponseElem.innerText = `${
submitResponse.statusText
}: ${await submitResponse.text()}`;
}
}
}
function formatChapterTime(playerTime, hasHours) {
let hours = Math.trunc(playerTime / 3600);
let minutes = Math.trunc((playerTime / 60) % 60);
let seconds = Math.trunc(playerTime % 60);
while (minutes.toString().length < 2) {
minutes = `0${minutes}`;
}
while (seconds.toString().length < 2) {
seconds = `0${seconds}`;
}
if (hasHours) {
return `${hours}:${minutes}:${seconds}`;
}
return `${minutes}:${seconds}`;
}
function unformatChapterTime(chapterTime) {
const timeParts = chapterTime.split(":");
while (timeParts.length < 3) {
timeParts.unshift(0);
}
const hours = +timeParts[0];
const minutes = +timeParts[1];
const seconds = +timeParts[2];
return hours * 3600 + minutes * 60 + seconds;
}
function handleFieldChange(_event) {
globalPageState = PAGE_STATE.DIRTY;
}
function handleLeavePage(event) {
if (globalPageState === PAGE_STATE.CLEAN) {
return;
}
event.preventDefault();
switch (globalPageState) {
case PAGE_STATE.DIRTY:
event.returnValue =
"There are unsaved edits. Are you sure you want to exit? You will lose your edits.";
break;
case PAGE_STATE.SUBMITTING:
event.returnValue =
"The video is stsill being submitted. Are you sure you want to exit? You may lose your edits.";
break;
case PAGE_STATE.CONFIRMING:
event.returnValue =
"There's a confirmation for video submission. Are you sure you want to exit? You will lose your edits.";
break;
}
return event.returnValue;
}
function generateDownloadURL(timeRanges, downloadType, allowHoles, quality) {
const queryParts = [`type=${downloadType}`, `allow_holes=${allowHoles}`];
for (const range of timeRanges) {
let timeRangeString = "";
if (range.hasOwnProperty("start")) {
timeRangeString += range.start;
}
timeRangeString += ",";
if (range.hasOwnProperty("end")) {
timeRangeString += range.end;
}
queryParts.push(`range=${timeRangeString}`);
}
const downloadURL = `/cut/${globalStreamName}/${quality}.ts?${queryParts.join("&")}`;
return downloadURL;
}
function updateDownloadLink() {
const downloadType = document.getElementById("download-type-select").value;
const allowHoles = document.getElementById("advanced-submission-option-allow-holes").checked;
const timeRanges = [];
for (const rangeContainer of document.getElementById("range-definitions").children) {
const startField = rangeContainer.getElementsByClassName("range-definition-start")[0];
const endField = rangeContainer.getElementsByClassName("range-definition-end")[0];
const timeRangeData = {};
const startTime = wubloaderTimeFromVideoHumanTime(startField.value);
if (startTime) {
timeRangeData.start = startTime;
}
const endTime = wubloaderTimeFromVideoHumanTime(endField.value);
if (endTime) {
timeRangeData.end = endTime;
}
timeRanges.push(timeRangeData);
}
const downloadURL = generateDownloadURL(
timeRanges,
downloadType,
allowHoles,
videoInfo.video_quality,
);
document.getElementById("download-link").href = downloadURL;
}
async function setManualVideoLink() {
let uploadLocation;
if (document.getElementById("data-correction-manual-link-youtube").checked) {
uploadLocation = "youtube-manual";
} else {
uploadLocation = "manual";
}
const link = document.getElementById("data-correction-manual-link-entry").value;
const request = {
link: link,
upload_location: uploadLocation,
};
if (googleUser) {
request.token = googleUser.getAuthResponse().id_token;
}
const responseElem = document.getElementById("data-correction-manual-link-response");
responseElem.innerText = "Submitting link...";
const response = await fetch(`/thrimshim/manual-link/${videoInfo.id}`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (response.ok) {
responseElem.innerText = `Manual link set to ${link}`;
} else {
responseElem.innerText = `${response.statusText}: ${await response.text()}`;
}
}
async function cancelVideoUpload() {
const request = {};
if (googleUser) {
request.token = googleUser.getAuthResponse().id_token;
}
const responseElem = document.getElementById("data-correction-cancel-response");
responseElem.innerText = "Submitting cancel request...";
const response = await fetch(`/thrimshim/reset/${videoInfo.id}?force=false`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (response.ok) {
responseElem.innerText = "Row has been cancelled.";
setTimeout(() => {
responseElem.innerText = "";
}, 2000);
} else {
responseElem.innerText = `${response.statusText}: ${await response.text()}`;
}
}
async function resetVideoRow() {
const request = {};
if (googleUser) {
request.token = googleUser.getAuthResponse().id_token;
}
const responseElem = document.getElementById("data-correction-cancel-response");
responseElem.innerText = "Submitting reset request...";
const response = await fetch(`/thrimshim/reset/${videoInfo.id}?force=true`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (response.ok) {
responseElem.innerText = "Row has been reset.";
const forceResetConfirmationContainer = document.getElementById(
"data-correction-force-reset-confirm",
);
forceResetConfirmationContainer.classList.add("hidden");
setTimeout(() => {
responseElem.innerText = "";
}, 2000);
} else {
responseElem.innerText = `${response.statusText}: ${await response.text()}`;
}
}
function addRangeDefinition() {
const newRangeDOM = rangeDefinitionDOM();
const rangeContainer = document.getElementById("range-definitions");
rangeContainer.appendChild(newRangeDOM);
}
function makeElement(tag, classes = [], values = {}) {
const element = document.createElement(tag);
for (const cls of classes) {
element.classList.add(cls);
}
for (const [key, value] of Object.entries(values)) {
element[key] = value;
}
return element;
}
function rangeDefinitionDOM() {
// Shortcut builder for image-based buttons
const button = (cls, src, alt) => makeElement("img", [cls, "click"], {
src, alt, title: alt,
});
const rangeContainer = makeElement("div", ["range-definition-removable"]);
const transitionContainer = makeElement("div", ["range-transition"]);
const transitionType = makeElement("select", ["range-transition-type"]);
// Always add the special-case hard cut option first.
transitionType.append(
makeElement("option", [], {
value: "",
textContent: "cut",
title: "A hard cut with no transition. Duration is ignored.",
}),
...(videoInfo.transitions ?? []).map(
type => makeElement("option", [], {
value: type.name,
textContent: type.name,
title: type.description,
})
),
);
const transitionDurationSection = makeElement("div", ["range-transition-duration-section"]);
if (transitionType.value == "") {
transitionDurationSection.classList.add("hidden");
}
const transitionDuration = makeElement("input", ["range-transition-duration"], {
type: "text",
value: "1",
});
transitionDurationSection.append(" over ", transitionDuration, " seconds");
transitionContainer.append("Transition: ", transitionType, transitionDurationSection);
const rangeTimesContainer = makeElement("div", ["range-definition-times"]);
const rangeStart = makeElement("input", ["range-definition-start"], {type: "text"});
const rangeStartSet = button(
"range-definition-set-start",
"images/pencil.png",
"Set range start point to the current video time",
);
const rangeStartPlay = button(
"range-definition-play-start",
"images/play_to.png",
"Play from start point",
);
const rangeTimeGap = makeElement("div", ["range-definition-between-time-gap"]);
const rangeEnd = makeElement("input", ["range-definition-end"], {type: "text"});
const rangeEndSet = button(
"range-definition-set-end",
"images/pencil.png",
"Set range end point to the current video time",
);
const rangeEndPlay = button(
"range-definition-play-end",
"images/play_to.png",
"Play from end point",
);
const removeRange = button(
"range-definition-remove",
"images/minus.png",
"Remove range",
);
if (canEditVideo()) {
rangeStartSet.addEventListener("click", getRangeSetClickHandler("start"));
rangeEndSet.addEventListener("click", getRangeSetClickHandler("end"));
} else {
rangeStartSet.classList.add("hidden");
rangeEndSet.classList.add("hidden");
rangeStart.disabled = true;
rangeEnd.disabled = true;
}
rangeStartPlay.addEventListener("click", rangePlayFromStartHandler);
rangeEndPlay.addEventListener("click", rangePlayFromEndHandler);
if (canEditVideo()) {
removeRange.addEventListener("click", (event) => {
handleFieldChange(event);
let rangeContainer = event.currentTarget;
while (rangeContainer && !rangeContainer.classList.contains("range-definition-removable")) {
rangeContainer = rangeContainer.parentElement;
}
if (rangeContainer) {
const rangeParent = rangeContainer.parentNode;
for (let rangeNum = 0; rangeNum < rangeParent.children.length; rangeNum++) {
if (rangeContainer === rangeParent.children[rangeNum]) {
if (rangeNum + 1 <= currentRange) {
// currentRange is 1-indexed to index into DOM with querySelector
currentRange--;
break;
}
}
}
rangeParent.removeChild(rangeContainer);
updateCurrentRangeIndicator();
rangeDataUpdated();
}
});
} else {
removeRange.classList.add("hidden");
}
const currentRangeMarkerAlt = "Range affected by keyboard shortcuts";
const currentRangeMarker = makeElement("img", ["range-definition-current", "hidden"], {
src: "images/arrow.png",
alt: currentRangeMarkerAlt,
title: currentRangeMarkerAlt,
});
rangeTimesContainer.append(
rangeStart,
rangeStartSet,
rangeStartPlay,
rangeTimeGap,
rangeEnd,
rangeEndSet,
rangeEndPlay,
removeRange,
currentRangeMarker,
);
const rangeChaptersContainer = makeElement("div", ["range-definition-chapter-markers"]);
const enableChaptersElem = document.getElementById("enable-chapter-markers");
const chaptersEnabled = enableChaptersElem.checked;
if (!chaptersEnabled) {
rangeChaptersContainer.classList.add("hidden");
}
const rangeAddChapterElem = button(
"add-range-definition-chapter-marker",
"images/plus.png",
"Add chapter marker",
);
if (!chaptersEnabled) {
rangeAddChapterElem.classList.add("hidden");
}
if (canEditMetadata()) {
rangeAddChapterElem.addEventListener("click", addChapterMarkerHandler);
} else {
rangeAddChapterElem.classList.add("hidden");
}
rangeContainer.append(
transitionContainer,
rangeTimesContainer,
rangeChaptersContainer,
rangeAddChapterElem,
);
return rangeContainer;
}
function getRangeSetClickHandler(startOrEnd) {
return (event) => {
if (!canEditVideo()) {
return;
}
const setButton = event.currentTarget;
const setField = setButton.parentElement.getElementsByClassName(
`range-definition-${startOrEnd}`,
)[0];
const videoElement = document.getElementById("video");
const videoPlayerTime = videoElement.currentTime;
setField.value = videoHumanTimeFromVideoPlayerTime(videoPlayerTime);
rangeDataUpdated();
};
}
function moveToNextRange() {
currentRange++;
if (
canEditVideo() &&
currentRange > document.getElementById("range-definitions").children.length
) {
addRangeDefinition();
}
updateCurrentRangeIndicator();
}
function moveToPreviousRange() {
if (currentRange <= 1) {
return;
}
currentRange--;
updateCurrentRangeIndicator();
}
function updateCurrentRangeIndicator() {
for (let arrowElem of document.getElementsByClassName("range-definition-current")) {
arrowElem.classList.add("hidden");
}
document
.querySelector(`#range-definitions > div:nth-child(${currentRange}) .range-definition-current`)
.classList.remove("hidden");
}
function rangePlayFromStartHandler(event) {
const playButton = event.currentTarget;
const startField = playButton.parentElement.getElementsByClassName("range-definition-start")[0];
const startTime = videoPlayerTimeFromVideoHumanTime(startField.value);
if (startTime === null) {
addError("Couldn't play from range start: failed to parse time");
return;
}
const videoElement = document.getElementById("video");
videoElement.currentTime = startTime;
}
function rangePlayFromEndHandler(event) {
const playButton = event.currentTarget;
const endField = playButton.parentElement.getElementsByClassName("range-definition-end")[0];
const endTime = videoPlayerTimeFromVideoHumanTime(endField.value);
if (endTime === null) {
addError("Couldn't play from range end; failed to parse time");
return;
}
const videoElement = document.getElementById("video");
videoElement.currentTime = endTime;
}
function chapterMarkerDefinitionDOM() {
const startFieldContainer = document.createElement("div");
startFieldContainer.classList.add("range-definition-chapter-marker-start-field");
const startField = document.createElement("input");
startField.type = "text";
startField.classList.add("range-definition-chapter-marker-start");
startField.placeholder = "Start time";
const setStartTime = document.createElement("img");
setStartTime.src = "images/pencil.png";
setStartTime.alt = "Set chapter start time";
setStartTime.title = setStartTime.alt;
setStartTime.classList.add("range-definition-chapter-marker-set-start");
setStartTime.classList.add("click");
setStartTime.addEventListener("click", (event) => {
const chapterContainer = event.currentTarget.parentElement;
const startTimeField = chapterContainer.getElementsByClassName(
"range-definition-chapter-marker-start",
)[0];
const videoElement = document.getElementById("video");
startTimeField.value = videoHumanTimeFromVideoPlayerTime(videoElement.currentTime);
});
const playFromStartTime = document.createElement("img");
playFromStartTime.src = "images/play_to.png";
playFromStartTime.alt = "Play from chapter start time";
playFromStartTime.title = playFromStartTime.alt;
playFromStartTime.classList.add("range-definition-chapter-marker-play-start");
playFromStartTime.classList.add("click");
if (canEditMetadata()) {
playFromStartTime.addEventListener("click", chapterMarkerPlayStartTimeHandler);
} else {
playFromStartTime.classList.add("hidden");
}
startFieldContainer.appendChild(startField);
startFieldContainer.appendChild(setStartTime);
startFieldContainer.appendChild(playFromStartTime);
const descriptionField = document.createElement("input");
descriptionField.type = "text";
descriptionField.classList.add("range-definition-chapter-marker-description");
descriptionField.placeholder = "Description";
descriptionField.addEventListener("input", (event) => {
validateChapterDescription(descriptionField);
});
const removeButton = document.createElement("img");
removeButton.src = "images/minus.png";
removeButton.alt = "Remove this chapter";
removeButton.title = removeButton.alt;
removeButton.classList.add("range-definition-chapter-marker-remove");
removeButton.classList.add("click");
if (canEditMetadata()) {
removeButton.addEventListener("click", (event) => {
const thisDefinition = event.currentTarget.parentElement;
thisDefinition.parentNode.removeChild(thisDefinition);
});
} else {
removeButton.classList.add("hidden");
}
const chapterContainer = document.createElement("div");
chapterContainer.appendChild(startFieldContainer);
chapterContainer.appendChild(descriptionField);
chapterContainer.appendChild(removeButton);
return chapterContainer;
}
function addChapterMarkerHandler(event) {
if (canEditMetadata()) {
const newChapterMarker = chapterMarkerDefinitionDOM();
event.currentTarget.previousElementSibling.appendChild(newChapterMarker);
handleFieldChange(event);
}
}
function chapterMarkerPlayStartTimeHandler(event) {
const chapterContainer = event.currentTarget.parentElement;
const startTimeField = chapterContainer.getElementsByClassName(
"range-definition-chapter-marker-start",
)[0];
const newVideoTime = videoPlayerTimeFromVideoHumanTime(startTimeField.value);
if (newVideoTime !== null) {
const videoElement = document.getElementById("video");
videoElement.currentTime = newVideoTime;
}
}
async function rangeDataUpdated() {
const clipBar = document.getElementById("clip-bar");
clipBar.innerHTML = "";
const videoElement = document.getElementById("video");
const videoDuration = videoElement.duration;
for (let rangeDefinition of document.getElementById("range-definitions").children) {
const rangeStartField = rangeDefinition.getElementsByClassName("range-definition-start")[0];
const rangeEndField = rangeDefinition.getElementsByClassName("range-definition-end")[0];
const rangeStart = videoPlayerTimeFromVideoHumanTime(rangeStartField.value);
const rangeEnd = videoPlayerTimeFromVideoHumanTime(rangeEndField.value);
if (rangeStart !== null && rangeEnd !== null) {
const rangeStartPercentage = (rangeStart / videoDuration) * 100;
const rangeEndPercentage = (rangeEnd / videoDuration) * 100;
const widthPercentage = rangeEndPercentage - rangeStartPercentage;
const marker = document.createElement("div");
marker.style.width = `${widthPercentage}%`;
marker.style.left = `${rangeStartPercentage}%`;
clipBar.appendChild(marker);
}
}
const firstRangeStartField = document.getElementsByClassName("range-definition-start")[0]; // There should always be a first one
const firstChapterStartField = document.getElementById(
"range-definition-chapter-marker-first-start",
);
firstChapterStartField.value = firstRangeStartField.value;
updateDownloadLink();
}
function setCurrentRangeStartToVideoTime() {
if (!canEditVideo()) {
return;
}
const rangeStartField = document.querySelector(
`#range-definitions > div:nth-child(${currentRange}) .range-definition-start`,
);
const videoElement = document.getElementById("video");
rangeStartField.value = videoHumanTimeFromVideoPlayerTime(videoElement.currentTime);
rangeDataUpdated();
}
function setCurrentRangeEndToVideoTime() {
if (!canEditVideo()) {
return;
}
const rangeEndField = document.querySelector(
`#range-definitions > div:nth-child(${currentRange}) .range-definition-end`,
);
const videoElement = document.getElementById("video");
rangeEndField.value = videoHumanTimeFromVideoPlayerTime(videoElement.currentTime);
rangeDataUpdated();
}
function enableChapterMarkers(enable) {
document.getElementById("enable-chapter-markers").checked = enable;
changeEnableChaptersHandler();
}
function changeEnableChaptersHandler() {
const chaptersEnabled = document.getElementById("enable-chapter-markers").checked;
for (const chapterMarkerContainer of document.getElementsByClassName(
"range-definition-chapter-markers",
)) {
if (chaptersEnabled) {
chapterMarkerContainer.classList.remove("hidden");
} else {
chapterMarkerContainer.classList.add("hidden");
}
}
for (const addChapterMarkerElem of document.getElementsByClassName(
"add-range-definition-chapter-marker",
)) {
if (chaptersEnabled) {
addChapterMarkerElem.classList.remove("hidden");
} else {
addChapterMarkerElem.classList.add("hidden");
}
}
}
function renderChatLog() {
const chatReplayParent = document.getElementById("chat-replay");
chatReplayParent.innerHTML = "";
for (const chatMessage of globalChatData) {
if (chatMessage.message.command === "PRIVMSG") {
const chatDOM = renderChatMessage(chatMessage);
if (chatDOM) {
chatReplayParent.appendChild(chatDOM);
}
} else if (chatMessage.message.command === "CLEARMSG") {
const removedMessageID = chatMessage.message.tags["target-msg-id"];
const removedMessageElem = document.getElementById(`chat-replay-message-${removedMessageID}`);
if (removedMessageElem) {
removedMessageElem.classList.add("chat-replay-message-cleared");
}
} else if (chatMessage.message.command === "CLEARCHAT") {
if (chatMessage.message.params.length > 1) {
const removedSender = chatMessage.message.params[1];
for (const childNode of document.getElementById("chat-replay").children) {
if (childNode.dataset.sender === removedSender) {
childNode.classList.add("chat-replay-message-cleared");
}
}
} else {
// Without a target parameter, the CLEARCHAT clears all messages in the entire chat.
for (const childNode of document.getElementById("chat-replay").children) {
childNode.classList.add("chat-replay-message-cleared");
}
}
} else if (chatMessage.message.command === "USERNOTICE") {
const chatDOMList = renderSystemMessages(chatMessage);
for (const chatDOM of chatDOMList) {
chatReplayParent.appendChild(chatDOM);
}
}
}
}
function videoPlayerTimeFromWubloaderTime(wubloaderTime) {
const wubloaderDateTime = dateTimeFromWubloaderTime(wubloaderTime);
const segmentList = getSegmentList();
for (let segmentIndex = 0; segmentIndex < segmentList.length - 1; segmentIndex++) {
const thisSegment = segmentList[segmentIndex];
const nextSegment = segmentList[segmentIndex + 1];
const segmentStartTime = DateTime.fromISO(thisSegment.rawProgramDateTime);
const nextSegmentStartTime = DateTime.fromISO(nextSegment.rawProgramDateTime);
if (segmentStartTime <= wubloaderDateTime && nextSegmentStartTime > wubloaderDateTime) {
let offset = wubloaderDateTime.diff(segmentStartTime).as("seconds");
// If there's a hole in the video and this wubloader time is in the hole, this will end up
// at a random point. We can fix that by capping the offset at the segment duration.
if (offset > thisSegment.duration) {
offset = thisSegment.duration;
}
return thisSegment.start + offset;
}
}
const lastSegment = segmentList[segmentList.length - 1];
const lastSegmentStartTime = DateTime.fromISO(lastSegment.rawProgramDateTime);
const lastSegmentEndTime = lastSegmentStartTime.plus({ seconds: lastSegment.duration });
if (lastSegmentStartTime <= wubloaderDateTime && wubloaderDateTime <= lastSegmentEndTime) {
return lastSegment.start + wubloaderDateTime.diff(lastSegmentStartTime).as("seconds");
}
return null;
}
function dateTimeFromVideoHumanTime(videoHumanTime) {
const videoPlayerTime = videoPlayerTimeFromVideoHumanTime(videoHumanTime);
if (videoPlayerTime === null) {
return null;
}
return dateTimeFromVideoPlayerTime(videoPlayerTime);
}
function wubloaderTimeFromVideoPlayerTime(videoPlayerTime) {
const dt = dateTimeFromVideoPlayerTime(videoPlayerTime);
return wubloaderTimeFromDateTime(dt);
}
function videoHumanTimeFromWubloaderTime(wubloaderTime) {
const videoPlayerTime = videoPlayerTimeFromWubloaderTime(wubloaderTime);
return videoHumanTimeFromVideoPlayerTime(videoPlayerTime);
}
function wubloaderTimeFromVideoHumanTime(videoHumanTime) {
const videoPlayerTime = videoPlayerTimeFromVideoHumanTime(videoHumanTime);
if (videoPlayerTime === null) {
return null;
}
return wubloaderTimeFromVideoPlayerTime(videoPlayerTime);
}
function canEditVideo() {
return (
videoInfo.state === "UNEDITED" || videoInfo.state === "EDITED" || videoInfo.state === "CLAIMED"
);
}
function canEditMetadata() {
return canEditVideo() || videoInfo.state === "DONE" || videoInfo.state === "MODIFIED";
}
function isNonVideoInput(element) {
return element.id.startsWith("data-correction-force-reset");
}