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 @@
+
-
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;