diff --git a/thrimbletrimmer/edit.html b/thrimbletrimmer/edit.html index 4fe1125..25dc45d 100644 --- a/thrimbletrimmer/edit.html +++ b/thrimbletrimmer/edit.html @@ -117,37 +117,51 @@ +
+ + +
-
- - Set range start point to current video time - Play from start point -
- - Set range end point to current video time - Play from end point -
+
+
+ + Set range start point to current video time + Play from start point +
+ + Set range end point to current video time + Play from end point +
+ Range affected by keyboard shortcuts +
+ Range affected by keyboard shortcuts
diff --git a/thrimbletrimmer/scripts/edit.js b/thrimbletrimmer/scripts/edit.js index 441c88f..c41e07b 100644 --- a/thrimbletrimmer/scripts/edit.js +++ b/thrimbletrimmer/scripts/edit.js @@ -2,6 +2,9 @@ var googleUser = null; var videoInfo; var currentRange = 1; +const CHAPTER_MARKER_DELIMITER = "\n==========\n"; +const CHAPTER_MARKER_DELIMITER_PARTIAL = "=========="; + window.addEventListener("DOMContentLoaded", async (event) => { commonPageSetup(); @@ -145,6 +148,11 @@ window.addEventListener("DOMContentLoaded", async (event) => { } }); + const enableChaptersElem = document.getElementById("enable-chapter-markers"); + enableChaptersElem.addEventListener("change", (_event) => { + changeEnableChaptersHandler(); + }); + for (const rangeStartSet of document.getElementsByClassName("range-definition-set-start")) { rangeStartSet.addEventListener("click", getRangeSetClickHandler("start")); } @@ -167,6 +175,11 @@ window.addEventListener("DOMContentLoaded", async (event) => { rangeDataUpdated(); }); } + for (const addChapterMarker of document.getElementsByClassName( + "add-range-definition-chapter-marker" + )) { + addChapterMarker.addEventListener("click", addChapterMarkerHandler); + } document.getElementById("video-info-title").addEventListener("input", (_event) => { validateVideoTitle(); @@ -396,31 +409,87 @@ async function initializeVideoInfo() { 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 = videoHumanTimeFromWubloaderTime(startWubloaderTime); + startField.value = videoHumanTimeFromVideoPlayerTime(startPlayerTime); } if (endWubloaderTime) { const endField = rangeDefinitionsContainer.children[rangeIndex].getElementsByClassName( "range-definition-end" )[0]; - endField.value = videoHumanTimeFromWubloaderTime(endWubloaderTime); + 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]; + 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(); + enableChapterMarkers(true); } } else { const rangeStartField = rangeDefinitionsContainer.getElementsByClassName("range-definition-start")[0]; rangeStartField.value = videoHumanTimeFromWubloaderTime(globalStartTimeString); + rangeStartField.dataset.oldTime = rangeStartField.value; if (globalEndTimeString) { const rangeEndField = rangeDefinitionsContainer.getElementsByClassName("range-definition-end")[0]; @@ -510,6 +579,9 @@ function validateVideoDescription() { } 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 = ""; @@ -517,6 +589,21 @@ function validateVideoDescription() { } async function submitVideo() { + const enableChaptersElem = document.getElementById("enable-chapter-markers"); + const chapterStartFieldList = document.getElementsByClassName("range-definition-chapter-time"); + if (enableChaptersElem.checked && chapterStartFieldList.length > 0) { + const firstRangeStartElem = document.getElementsByClassName("range-definition-start")[0]; + const firstRangeStart = videoPlayerTimeFromMVideoHumanTime(firstRangeStartElem.value); + + const firstChapterStartField = chapterStartFieldList[0]; + const firstChapterStart = videoPlayerTimeFromVideoHumanTime(firstChapterStartField.value); + + if (firstRangeStart !== firstChapterStart) { + addError("The first chapter marker must be at the beginning of the video"); + return; + } + } + return sendVideoData(true, false); } @@ -525,17 +612,31 @@ async function saveVideoDraft() { } async function sendVideoData(edited, 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 submissionResponseElem = document.getElementById("submission-response"); submissionResponseElem.classList.value = ["submission-response-pending"]; submissionResponseElem.innerText = "Submitting video..."; window.addEventListener("beforeunload", handleLeavePageWhilePending); const rangesData = []; + const chaptersData = []; + const chaptersEnabled = document.getElementById("enable-chapter-markers").checked; + let rangeStartInFinalVideo = 0; for (const rangeContainer of document.getElementById("range-definitions").children) { - const rangeStart = rangeContainer.getElementsByClassName("range-definition-start")[0].value; - const rangeEnd = rangeContainer.getElementsByClassName("range-definition-end")[0].value; - const rangeStartSubmit = wubloaderTimeFromVideoHumanTime(rangeStart); - const rangeEndSubmit = wubloaderTimeFromVideoHumanTime(rangeEnd); + 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"]; @@ -551,11 +652,66 @@ async function sendVideoData(edited, overrideChanges) { 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) { + 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, + description: descField.value, + }; + chaptersData.push(chapterData); + } + } 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 = []; @@ -567,8 +723,38 @@ async function sendVideoData(edited, overrideChanges) { // 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 (chaptersData[chapterIndex].start < lastChapterStart) { + submissionResponseElem.innerText = "Chapters are out of order"; + submissionResponseElem.classList.value = ["submission-response-error"]; + return; + } + 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 videoTitle = document.getElementById("video-info-title").value; - const videoDescription = document.getElementById("video-info-description").value; const videoTags = document.getElementById("video-info-tags").value.split(","); const allowHoles = document.getElementById("advanced-submission-option-allow-holes").checked; const uploadLocation = document.getElementById( @@ -660,6 +846,36 @@ async function sendVideoData(edited, overrideChanges) { } } +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 handleLeavePageWhilePending(event) { event.preventDefault(); event.returnValue = @@ -815,8 +1031,9 @@ function addRangeDefinition() { function rangeDefinitionDOM() { const rangeContainer = document.createElement("div"); - rangeContainer.classList.add("range-definition-removable"); - rangeContainer.classList.add("range-definition-times"); + const rangeTimesContainer = document.createElement("div"); + rangeTimesContainer.classList.add("range-definition-removable"); + rangeTimesContainer.classList.add("range-definition-times"); const rangeStart = document.createElement("input"); rangeStart.type = "text"; rangeStart.classList.add("range-definition-start"); @@ -885,15 +1102,38 @@ function rangeDefinitionDOM() { currentRangeMarker.classList.add("range-definition-current"); currentRangeMarker.classList.add("hidden"); - rangeContainer.appendChild(rangeStart); - rangeContainer.appendChild(rangeStartSet); - rangeContainer.appendChild(rangeStartPlay); - rangeContainer.appendChild(rangeTimeGap); - rangeContainer.appendChild(rangeEnd); - rangeContainer.appendChild(rangeEndSet); - rangeContainer.appendChild(rangeEndPlay); - rangeContainer.appendChild(removeRange); - rangeContainer.appendChild(currentRangeMarker); + rangeTimesContainer.appendChild(rangeStart); + rangeTimesContainer.appendChild(rangeStartSet); + rangeTimesContainer.appendChild(rangeStartPlay); + rangeTimesContainer.appendChild(rangeTimeGap); + rangeTimesContainer.appendChild(rangeEnd); + rangeTimesContainer.appendChild(rangeEndSet); + rangeTimesContainer.appendChild(rangeEndPlay); + rangeTimesContainer.appendChild(removeRange); + rangeTimesContainer.appendChild(currentRangeMarker); + + const rangeChaptersContainer = document.createElement("div"); + const enableChaptersElem = document.getElementById("enable-chapter-markers"); + const chaptersEnabled = enableChaptersElem.checked; + rangeChaptersContainer.classList.add("range-definition-chapter-markers"); + if (!chaptersEnabled) { + rangeChaptersContainer.classList.add("hidden"); + } + + const rangeAddChapterElem = document.createElement("img"); + rangeAddChapterElem.src = "images/plus.png"; + rangeAddChapterElem.alt = "Add chapter marker"; + rangeAddChapterElem.title = "Add chapter marker"; + rangeAddChapterElem.classList.add("add-range-definition-chapter-marker"); + rangeAddChapterElem.classList.add("click"); + if (!chaptersEnabled) { + rangeAddChapterElem.classList.add("hidden"); + } + rangeAddChapterElem.addEventListener("click", addChapterMarkerHandler); + + rangeContainer.appendChild(rangeTimesContainer); + rangeContainer.appendChild(rangeChaptersContainer); + rangeContainer.appendChild(rangeAddChapterElem); return rangeContainer; } @@ -965,6 +1205,62 @@ function rangePlayFromEndHandler(event) { videoElement.currentTime = endTime; } +function chapterMarkerDefinitionDOM() { + const startFieldContainer = document.createElement("div"); + 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); + }); + + startFieldContainer.appendChild(startField); + startFieldContainer.appendChild(setStartTime); + + const descriptionField = document.createElement("input"); + descriptionField.type = "text"; + descriptionField.classList.add("range-definition-chapter-marker-description"); + descriptionField.placeholder = "Description"; + + 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"); + + removeButton.addEventListener("click", (event) => { + const thisDefinition = event.currentTarget.parentElement; + thisDefinition.parentNode.removeChild(thisDefinition); + }); + + const chapterContainer = document.createElement("div"); + chapterContainer.appendChild(startFieldContainer); + chapterContainer.appendChild(descriptionField); + chapterContainer.appendChild(removeButton); + + return chapterContainer; +} + +function addChapterMarkerHandler(event) { + const newChapterMarker = chapterMarkerDefinitionDOM(); + event.currentTarget.previousElementSibling.appendChild(newChapterMarker); +} + function rangeDataUpdated() { const clipBar = document.getElementById("clip-bar"); clipBar.innerHTML = ""; @@ -978,18 +1274,45 @@ function rangeDataUpdated() { const rangeStart = videoPlayerTimeFromVideoHumanTime(rangeStartField.value); const rangeEnd = videoPlayerTimeFromVideoHumanTime(rangeEndField.value); - if (rangeStart === null || rangeEnd === null) { - continue; - } + if (rangeStart !== null && rangeEnd !== null) { + const rangeStartPercentage = (rangeStart / videoDuration) * 100; + const rangeEndPercentage = (rangeEnd / videoDuration) * 100; + const widthPercentage = rangeEndPercentage - rangeStartPercentage; - 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 marker = document.createElement("div"); - marker.style.width = `${widthPercentage}%`; - marker.style.left = `${rangeStartPercentage}%`; - clipBar.appendChild(marker); + let oldRangeStart = rangeStartField.dataset.oldTime; + let oldRangeEnd = rangeEndField.dataset.oldTime; + if (oldRangeStart) { + oldRangeStart = videoPlayerTimeFromVideoHumanTime(oldRangeStart); + } else { + oldRangeStart = null; + } + if (oldRangeEnd) { + oldRangeEnd = videoPlayerTimeFromVideoHumanTime(oldRnageEnd); + } else { + oldRangeEnd = null; + } + if (rangeStart === null) { + delete rangeStartField.dataset.oldTime; + } else if (oldRangeStart === null) { + rangeStartField.dataset.oldTime = rangeStartField.value; + } else { + const startOffset = rangeStart - oldRangeStart; + for (const chapterStartField of rangeDefinition.getElementsByClassName( + "range-definition-chapter-marker-start" + )) { + const chapterStart = videoPlayerTimeFromVideoHumanTime(chapterStartField.value); + if (chapterStart !== null) { + chapterStartField.value = videoHumanTimeFromVideoPlayerTime(chapterStart + startOffset); + } + } + rangeStartField.dataset.oldTime = rangeStartField.value; + } } updateDownloadLink(); } @@ -1012,6 +1335,33 @@ function setCurrentRangeEndToVideoTime() { 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 videoPlayerTimeFromWubloaderTime(wubloaderTime) { const wubloaderDateTime = dateTimeFromWubloaderTime(wubloaderTime); const segmentList = getSegmentList(); diff --git a/thrimbletrimmer/styles/thrimbletrimmer.css b/thrimbletrimmer/styles/thrimbletrimmer.css index b073225..8967d47 100644 --- a/thrimbletrimmer/styles/thrimbletrimmer.css +++ b/thrimbletrimmer/styles/thrimbletrimmer.css @@ -262,6 +262,23 @@ a, margin-top: 2px; } +.range-definition-chapter-markers > div { + display: flex; + align-items: center; + gap: 10px; + margin-left: 30px; +} + +.range-definition-chapter-marker-start { + width: 100px; + text-align: right; +} + +.add-range-definition-chapter-marker { + margin-left: 30px; + margin-bottom: 7px; +} + #video-info { margin: 5px 0; display: grid;