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 ] ;
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 ) ;
}
}
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 = "" ;
}
}
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" ) ;
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 rangeDefinitionDOM ( ) {
const rangeContainer = document . createElement ( "div" ) ;
rangeContainer . classList . add ( "range-definition-removable" ) ;
const rangeTimesContainer = document . createElement ( "div" ) ;
rangeTimesContainer . classList . add ( "range-definition-times" ) ;
const rangeStart = document . createElement ( "input" ) ;
rangeStart . type = "text" ;
rangeStart . classList . add ( "range-definition-start" ) ;
const rangeStartSet = document . createElement ( "img" ) ;
rangeStartSet . src = "images/pencil.png" ;
rangeStartSet . alt = "Set range start point to the current video time" ;
rangeStartSet . title = rangeStartSet . alt ;
rangeStartSet . classList . add ( "range-definition-set-start" ) ;
rangeStartSet . classList . add ( "click" ) ;
const rangeStartPlay = document . createElement ( "img" ) ;
rangeStartPlay . src = "images/play_to.png" ;
rangeStartPlay . alt = "Play from start point" ;
rangeStartPlay . title = rangeStartPlay . alt ;
rangeStartPlay . classList . add ( "range-definition-play-start" ) ;
rangeStartPlay . classList . add ( "click" ) ;
const rangeTimeGap = document . createElement ( "div" ) ;
rangeTimeGap . classList . add ( "range-definition-between-time-gap" ) ;
const rangeEnd = document . createElement ( "input" ) ;
rangeEnd . type = "text" ;
rangeEnd . classList . add ( "range-definition-end" ) ;
const rangeEndSet = document . createElement ( "img" ) ;
rangeEndSet . src = "images/pencil.png" ;
rangeEndSet . alt = "Set range end point to the current video time" ;
rangeEndSet . title = rangeEndSet . alt ;
rangeEndSet . classList . add ( "range-definition-set-end" ) ;
rangeEndSet . classList . add ( "click" ) ;
const rangeEndPlay = document . createElement ( "img" ) ;
rangeEndPlay . src = "images/play_to.png" ;
rangeEndPlay . alt = "Play from end point" ;
rangeEndPlay . title = rangeEndPlay . alt ;
rangeEndPlay . classList . add ( "range-definition-play-end" ) ;
rangeEndPlay . classList . add ( "click" ) ;
const removeRange = document . createElement ( "img" ) ;
removeRange . alt = "Remove range" ;
removeRange . title = removeRange . alt ;
removeRange . src = "images/minus.png" ;
removeRange . classList . add ( "range-definition-remove" ) ;
removeRange . classList . add ( "click" ) ;
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 currentRangeMarker = document . createElement ( "img" ) ;
currentRangeMarker . alt = "Range affected by keyboard shortcuts" ;
currentRangeMarker . title = "Range affected by keyboard shortcuts" ;
currentRangeMarker . src = "images/arrow.png" ;
currentRangeMarker . classList . add ( "range-definition-current" ) ;
currentRangeMarker . classList . add ( "hidden" ) ;
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" ) ;
}
if ( canEditMetadata ( ) ) {
rangeAddChapterElem . addEventListener ( "click" , addChapterMarkerHandler ) ;
} else {
rangeAddChapterElem . classList . add ( "hidden" ) ;
}
rangeContainer . appendChild ( rangeTimesContainer ) ;
rangeContainer . appendChild ( rangeChaptersContainer ) ;
rangeContainer . appendChild ( 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" , ( 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 ;
}
} ) ;
} 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" ;
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 ) ;
}
}
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 ) ;
}
}
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" ) ;
}