|
// ==UserScript== |
|
// @name Force-update on Page Load |
|
// @namespace sb.ltn.fi.force.update |
|
// @version 1.0.1 |
|
// @description Fetches the segments and updated info for the video, so that you don't have to wait for the video page to update. |
|
// @author TheJzoli |
|
// @match https://sb.ltn.fi/video/* |
|
// @match https://sb.ltn.fi/uuid/* |
|
// @icon https://sb.ltn.fi/static/browser/logo.png |
|
// @grant GM_xmlhttpRequest |
|
// @grant GM_setClipboard |
|
// @connect sponsor.ajay.app |
|
// @updateURL https://gist.github.com/Krashgamer/c8ed1bc131a214e1fcc84338b73f11b7/raw/sb.ltn.fi.forceupdate.user.js |
|
// @downloadURL https://gist.github.com/Krashgamer/c8ed1bc131a214e1fcc84338b73f11b7/raw/sb.ltn.fi.forceupdate.user.js |
|
// ==/UserScript== |
|
|
|
(function() { |
|
'use strict'; |
|
|
|
const SBLTNFI_TO_API_PARAMS = {views_min: 'minViews', views_max: 'maxViews', votes_min: 'minVotes', votes_max: 'maxVotes', category: 'category'}; |
|
const BASEAPIURL = 'https://sponsor.ajay.app/api'; |
|
|
|
const ignoreParams = ['shadowhidden', 'uuid', 'username', 'user', 'sort', 'page']; |
|
const exclude = ['input', 'textarea']; |
|
|
|
const listNextToVideoEmbed = document.getElementsByClassName('list-group')[document.getElementsByClassName('list-group').length - 1]; |
|
|
|
// Force-update when Page Finished loading |
|
addRefreshButton(); |
|
window.addEventListener('load', function () { |
|
const videoID = checkPageVideoID(); |
|
if (!videoID) return; |
|
getAndUpdateSegments(videoID); |
|
getAndUpdateLocks(videoID); |
|
} |
|
); |
|
|
|
/** |
|
* Gets all the video's segments, then calls the {@link updateTable} function, and updates the submission amount. |
|
* @param {string} videoID ID of the video whose segments we want to fetch |
|
*/ |
|
function getAndUpdateSegments(videoID) { |
|
document.body.style.cursor = 'progress'; |
|
let segmentsArray = []; |
|
const searchParams = new URLSearchParams(location.search); |
|
const filters = buildFilterParam(searchParams); |
|
let page = 0; |
|
let segmentCount = 0; |
|
const callback = (responseObject) => { |
|
if (responseObject.status !== 200) { |
|
if (responseObject.status === 404) { |
|
window.alert('No segments found for video'); |
|
} else { |
|
window.alert(`API returned a status of ${responseObject.status} when getting segments`); |
|
} |
|
document.body.style.cursor = 'default'; |
|
return; |
|
} |
|
segmentCount = responseObject.response.segmentCount; |
|
segmentsArray = segmentsArray.concat(responseObject.response.segments); |
|
if (segmentCount > 10 && (page+1)*10 < segmentCount) { |
|
getSegments(videoID, filters, ++page, callback); |
|
} else if (segmentsArray.length === segmentCount) { |
|
// Update table once all segments have been fetched |
|
updateTable(segmentsArray, searchParams); |
|
// Update submission amount |
|
getDivThatStartsWithText('Query results:').textContent = `Query results: ${responseObject.response.segmentCount}`; |
|
// If total submissions is over current query's segment count, don't update it. |
|
if (listNextToVideoEmbed.firstElementChild.textContent.split(':')[1].trim() < responseObject.response.segmentCount) { |
|
listNextToVideoEmbed.firstElementChild.textContent = `Submissions: ${responseObject.response.segmentCount}`; |
|
} |
|
// Update ignored amount |
|
let hiddenAmount = 0; |
|
let ignoredAmount = 0; |
|
segmentsArray.forEach(segment => { |
|
hiddenAmount += segment.hidden; |
|
ignoredAmount += segment.shadowHidden || segment.votes <= -2 ? 1 : 0; |
|
}); |
|
listNextToVideoEmbed.children[1].textContent = `Ignored: ${ignoredAmount} `; |
|
listNextToVideoEmbed.children[1].appendChild(createSpan('Segments that are also not sent to users, but don\'t count as ignored', `+ ${hiddenAmount} ❓`)); |
|
// Remove the "no submissions exist" alert if it exists. This would be on the page if the page didn't have any segments before updating. |
|
document.querySelector("[role=alert]")?.remove(); |
|
|
|
document.body.style.cursor = 'default'; |
|
// Fire event to signal other scripts of force refresh |
|
const event = new CustomEvent("forceRefresh"); |
|
document.dispatchEvent(event); |
|
} |
|
}; |
|
getSegments(videoID, filters, page, callback); // TODO only call for specific pages based on SBB page |
|
} |
|
|
|
/** |
|
* Calls the SponsorBlock API |
|
* @param {string} videoID ID of the video whose segments we want to fetch |
|
* @param {string} filters Filter params e.g. minVotes etc. |
|
* @param {number} page The page number we want from the API. 1 page is 10 segments |
|
* @param {function} callback callback function to call when onload triggers |
|
*/ |
|
function getSegments(videoID, filters, page, callback) { |
|
GM_xmlhttpRequest({ |
|
method: 'GET', |
|
url: `${BASEAPIURL}/searchSegments?videoID=${videoID+filters}&page=${page}`, |
|
responseType: 'json', |
|
timeout: 10000, |
|
onload: callback, |
|
onerror: () => { |
|
document.body.style.cursor = 'default'; |
|
window.alert('An error occurred getting the segments!'); |
|
}, |
|
ontimeout: () => { |
|
document.body.style.cursor = 'default'; |
|
window.alert('Segments request timed out! API didn\'t answer in time.'); |
|
} |
|
}); |
|
} |
|
|
|
|
|
/** |
|
* Gets the video's locked categories and updates them. |
|
* @param {string} videoID ID of the video whose locked categories we want |
|
*/ |
|
function getAndUpdateLocks(videoID) { |
|
const skipCallback = (responseObject) => { |
|
updateLockedCategories(responseObject, 'Locked skips: ', 2); |
|
}; |
|
const muteCallback = (responseObject) => { |
|
updateLockedCategories(responseObject, 'Locked mutes: ', 3); |
|
}; |
|
const fullCallback = (responseObject) => { |
|
updateLockedCategories(responseObject, 'Locked full: ', 4); |
|
}; |
|
getLocks(videoID, 'skip', skipCallback); |
|
getLocks(videoID, 'mute', muteCallback); |
|
getLocks(videoID, 'full', fullCallback); |
|
} |
|
|
|
/** |
|
* Calls the SponsorBlock API to get the locked categories with the specified actionType for the specified video. |
|
* @param {string} videoID ID of the video whose locked categories we want |
|
* @param {string} actionType Action type of the locked categories |
|
* @param {function} callback callback function to call when the request loads |
|
*/ |
|
function getLocks(videoID, actionType, callback) { |
|
GM_xmlhttpRequest({ |
|
method: 'GET', |
|
url: `${BASEAPIURL}/lockCategories?videoID=${videoID}&actionTypes=["${actionType}"]`, |
|
responseType: 'json', |
|
timeout: 10000, |
|
onload: callback, |
|
onerror: () => { |
|
window.alert(`An error occurred getting the locked categories with action type '${actionType}'!`); |
|
}, |
|
ontimeout: () => { |
|
window.alert(`Locked categories (with action type '${actionType}') request timed out! API didn't answer in time.`); |
|
} |
|
}); |
|
} |
|
|
|
/** |
|
* Updates one of the actionTypes locked categories next to the embed player. |
|
* @param {Object} responseObject onload response from the GM_xmlhttpRequest |
|
* @param {string} startingString starting part of the locked categories string |
|
* @param {number} index index of the actionType in the list next to the embed player |
|
*/ |
|
function updateLockedCategories(responseObject, startingString, index) { |
|
if (responseObject.status !== 200 && responseObject.status !== 404) { |
|
let actionType = startingString.slice(7, -3); |
|
if (actionType.length === 3) actionType += 'l'; |
|
window.alert(`API returned a status of ${responseObject.status} when getting locked categories with action type ${actionType}`); |
|
} |
|
let lockedString = startingString; |
|
const lockedStringStartingLength = lockedString.length; |
|
responseObject.response?.categories?.forEach((category, i) => { |
|
if (i !== 0) { |
|
lockedString += ', '; |
|
} |
|
lockedString += category; |
|
}); |
|
lockedString += lockedString.length === lockedStringStartingLength ? '—' : ''; |
|
listNextToVideoEmbed.children[index].textContent = lockedString; |
|
} |
|
|
|
/** |
|
* Updates the table with the new data from the provided array |
|
* @param {Object[]} segmentsArray Array of the segments fetched from the API |
|
* @param {URLSearchParams} searchParams Search parameters of the current page |
|
*/ |
|
function updateTable(segmentsArray, searchParams) { |
|
[...document.querySelectorAll('table')].forEach(table => { |
|
const headers = [...table.querySelectorAll('thead th')].map(item => item.textContent.trim()); |
|
if (headers.indexOf('UUID') === -1) { |
|
return; |
|
} |
|
const rows = [...table.querySelectorAll('tbody tr')]; |
|
rows.forEach(row => updateRow(row, headers, segmentsArray)); // Update info for the old segments on the page |
|
|
|
// Don't add new segments if on a page other than 1 (TODO remove when page support gets added) |
|
let currentPage = parseInt(searchParams.get('page')); |
|
currentPage = isNaN(currentPage) ? 1 : currentPage; |
|
if (currentPage !== 1) { |
|
return; |
|
} |
|
// Add new segments to the page |
|
const lastUpdateElement = getDivThatStartsWithText('Last update:'); |
|
const lastUpdateTime = dateToMillis(lastUpdateElement.textContent.trim().slice(13, 32).replaceAll(' ', 'T') + 'Z'); |
|
const newSegments = segmentsArray.filter((segment) => { |
|
if (segment.timeSubmitted > lastUpdateTime) { |
|
let addSegment = true; |
|
if (searchParams.has('shadowhidden')) { |
|
addSegment = addSegment && (segment.shadowHidden.toString() === searchParams.get('shadowhidden')); |
|
} |
|
if (searchParams.has('uuid')) { |
|
addSegment = addSegment && (segment.UUID === searchParams.get('uuid')); |
|
} |
|
if (searchParams.has('user')) { |
|
addSegment = addSegment && (segment.userID === searchParams.get('user')); |
|
} |
|
return addSegment; |
|
} |
|
return false; |
|
}); |
|
const tableBody = table.getElementsByTagName('tbody')[0]; |
|
newSegments.forEach(segment => { |
|
for (const row of rows) { // Check if the new segment is already on the page from a previous force refresh |
|
if (row.children[headers.indexOf('UUID')].firstElementChild.value === segment.UUID) { |
|
return; |
|
} |
|
} |
|
tableBody.insertAdjacentElement('afterbegin', createRowElement(segment)); |
|
}); |
|
fixRows(tableBody); |
|
// Fire event to signal other scripts of new segments |
|
if (newSegments.length !== 0) { |
|
const event = new CustomEvent("newSegments"); |
|
document.dispatchEvent(event); |
|
} |
|
if (newSegments.length > 0 && searchParams.has('username')) { // Notify user that filtering by username isn't supported |
|
window.alert('Filtering by username is not supported due to API limitations. \nNew added segments may be from different users'); |
|
} |
|
/* TODO Add support for different sort options and pages |
|
switch (searchParams.get('sort')) { |
|
case 'timesubmitted': // old -> new |
|
break; |
|
case 'starttime': |
|
break; |
|
case 'endtime': |
|
break; |
|
case 'length': |
|
break; |
|
case 'votes': |
|
break; |
|
case 'views': |
|
break; |
|
case 'category': |
|
break; |
|
case 'shadowhidden': |
|
break; |
|
case 'uuid': |
|
break; |
|
case 'username': |
|
break; |
|
case 'actiontype': |
|
break; |
|
case 'hidden': |
|
break; |
|
case 'userid': |
|
break; |
|
case '-userid': |
|
break; |
|
case '-starttime': |
|
break; |
|
case '-endtime': |
|
break; |
|
case '-length': |
|
break; |
|
case '-votes': |
|
break; |
|
case '-views': |
|
break; |
|
case '-category': |
|
break; |
|
case '-shadowhidden': |
|
break; |
|
case '-uuid': |
|
break; |
|
case '-username': |
|
break; |
|
case '-actiontype': |
|
break; |
|
case '-hidden': |
|
break; |
|
case '-timesubmitted': // new -> old |
|
default: // same as -timesubmitted |
|
} |
|
*/ |
|
}); |
|
} |
|
|
|
/** |
|
* Updates the data for the segment in this row |
|
* @param {HTMLTableRowElement} row Row to be updated |
|
* @param {string[]} headers String array of the table's headers |
|
* @param {Object[]} segmentsArray Array of the segments fetched from the API |
|
*/ |
|
function updateRow(row, headers, segmentsArray) { |
|
const uuid = row.children[headers.indexOf('UUID')].firstElementChild.value; |
|
const oldSegmentIndex = segmentsArray.findIndex(segment => segment.UUID === uuid); // Find the index for the segment on the page, so we can get its updated info from the API |
|
if (oldSegmentIndex === -1) { |
|
return; // If there is a segment UUID on the page that isn't on the API. |
|
} |
|
// Update Votes column |
|
const votesEl = row.children[headers.indexOf('Votes')]; |
|
let underNegTwoVotesSpan; |
|
let lockedSpan; |
|
[...votesEl.children].forEach(el => { // Check for existing icons and save their element |
|
if (el.textContent === '❌') { |
|
underNegTwoVotesSpan = el; |
|
return; |
|
} |
|
if (el.textContent === '🔒') { |
|
lockedSpan = el; |
|
} |
|
}); |
|
votesEl.firstChild.textContent = segmentsArray[oldSegmentIndex].votes; // Update votes from API |
|
if (segmentsArray[oldSegmentIndex].votes <= -2) { // Add X symbol if votes under -1 and it already isn't there |
|
if (!underNegTwoVotesSpan) { |
|
votesEl.firstChild.after(createSpan('This segment is not sent to users', '❌')); |
|
} |
|
} else if (underNegTwoVotesSpan) { // Delete X symbol if it is there and votes are over -2 |
|
underNegTwoVotesSpan.remove(); |
|
} |
|
if (segmentsArray[oldSegmentIndex].locked) { // Add locked symbol if segment is locked and it already isn't there |
|
if (!lockedSpan) { |
|
votesEl.firstChild.after(createSpan('This segment is locked by a VIP', '🔒')); |
|
} |
|
} else if (lockedSpan) { // Delete locked symbol if it is there and the segment isn't locked |
|
lockedSpan.remove(); |
|
} |
|
|
|
// Update Views column |
|
const viewsEl = row.children[headers.indexOf('Views')]; |
|
viewsEl.textContent = segmentsArray[oldSegmentIndex].views; |
|
|
|
// Update Category column |
|
const newCategory = segmentsArray[oldSegmentIndex].category; |
|
const categoryEl = row.children[headers.indexOf('Category')]; |
|
const span = categoryEl.getElementsByTagName('span'); |
|
const categorySpanEl = span[0]?.id === 'colorSquare' ? span[1]: span[0]; // colored categories userscript or chapter span |
|
const buttons = categoryEl.getElementsByTagName('button'); |
|
const categoryButton = buttons.length !== 0 && buttons[0].hasAttribute('id') ? buttons[0] : undefined; // VIP tools userscript |
|
if (newCategory !== 'chapter') { // Chapters cannot be changed to other categories |
|
if (categoryButton) { // Check for VIP tools userscript button |
|
categoryButton.firstChild.textContent = newCategory; |
|
} |
|
if (categorySpanEl && categorySpanEl.classList[0] === 'mruy_sbcc') { // Check for colored categories userscript |
|
const newClass = categorySpanEl.classList[1].slice(0, 10) + segmentsArray[oldSegmentIndex].category; |
|
categorySpanEl.classList.replace(categorySpanEl.classList[1], newClass); |
|
categorySpanEl.textContent = newCategory; |
|
} else if (!categoryButton) { // If no colored categories, or vip tools userscripts. |
|
if (categoryEl.firstElementChild?.hasAttribute('id')) { // check for colored square span |
|
categoryEl.childNodes[1].textContent = newCategory; |
|
} else { // if no other script that messes with the category column |
|
categoryEl.firstChild.textContent = newCategory; |
|
} |
|
} |
|
} |
|
// Update Shadowhidden column |
|
const shadowHiddenEl = row.children[headers.indexOf('Shadowhidden')]; |
|
shadowHiddenEl.innerHTML = ''; // empty the element |
|
shadowHiddenEl.appendChild(segmentsArray[oldSegmentIndex].shadowHidden ? createSpan('This segment has been shadowhidden.', '❌') : document.createTextNode('—')); |
|
|
|
// Update Hidden column |
|
const hiddenEl = row.children[headers.indexOf('Hidden')]; |
|
hiddenEl.innerHTML = ''; // empty the element |
|
hiddenEl.appendChild(segmentsArray[oldSegmentIndex].hidden ? createSpan('This segment is hidden due to video duration change.', '❌') : document.createTextNode('—')); |
|
|
|
// Update S/H column if Hide Columns userscript is being used |
|
const shIndex = headers.indexOf('S/H'); |
|
if (shIndex !== -1) { |
|
const shEl = row.children[shIndex]; |
|
shEl.textContent = segmentsArray[oldSegmentIndex].shadowHidden || segmentsArray[oldSegmentIndex].hidden ? '❌' : '—'; |
|
} |
|
} |
|
|
|
/** |
|
* Builds a string from the given parameters. |
|
* Transforms the parameters from the way they appear in SBB's url, to what SponsorBlock's API accepts. |
|
* @param {URLSearchParams} searchParams The parameters from SBB's url with which we want to call the API |
|
* @returns {string} Parameters string that should be appended to the API request url |
|
*/ |
|
function buildFilterParam(searchParams) { |
|
let paramString = ''; |
|
searchParams.forEach((value, key) => { |
|
if (ignoreParams.includes(key)) { |
|
return; |
|
} |
|
paramString += `&${SBLTNFI_TO_API_PARAMS[key]}=${value}`; |
|
}); |
|
return paramString; |
|
} |
|
|
|
/** |
|
* Returns the div element that starts with the specified text. |
|
* @param {string} text Text we want to search |
|
* @returns {HTMLDivElement} |
|
*/ |
|
function getDivThatStartsWithText(text) { |
|
const divElements = Array.from(document.getElementsByTagName('div')); |
|
const result = divElements.filter(element => element.innerHTML.trim().slice(0, text.length) === text); |
|
if (result.length !== 0) { |
|
return result[0]; |
|
} |
|
} |
|
|
|
/** |
|
* Converts milliseconds to a date. |
|
* @param {number | string} millis Milliseconds to be converted to a date |
|
* @returns {string} The date in a YYYY-MM-DD HH:MM:SS format |
|
*/ |
|
function millisToDate(millis) { |
|
return new Date(millis).toISOString().slice(0, -5).replaceAll('T', ' '); |
|
} |
|
|
|
/** |
|
* Converts date string to milliseconds. |
|
* @param {string} date Date string. Date should conform to the ISO 8601 format "YYYY-MM-DDTHH:mm:ssZ" |
|
* @returns {number} |
|
*/ |
|
function dateToMillis(date) { |
|
return new Date(date).getTime(); |
|
} |
|
|
|
/** |
|
* Converts the time from seconds to hours, minutes, and seconds. |
|
* @param {number | string} timeInSeconds Seconds to be converted |
|
* @returns {string} The time in H:MM:SS.mmmmmm format |
|
*/ |
|
function secondsToTime(timeInSeconds) { |
|
const pad = num => { return ('0' + num).slice(-2) }, |
|
time = parseFloat(timeInSeconds).toFixed(6), |
|
hours = Math.floor(time / 60 / 60), |
|
minutes = Math.floor(time / 60) % 60, |
|
seconds = Math.floor(time - minutes * 60), |
|
milliseconds = time.split('.')[1]; |
|
|
|
return hours + ':' + pad(minutes) + ':' + pad(seconds) + (milliseconds === '000000' ? '' : '.' + milliseconds); |
|
} |
|
|
|
/** |
|
* Creates a row element and returns it |
|
* @param {Object} segment Segment from the API whose data is used to build the row |
|
* @returns {HTMLTableRowElement} |
|
*/ |
|
function createRowElement(segment) { |
|
const row = document.createElement('tr'); |
|
const submittedColumn = document.createElement('td'); |
|
submittedColumn.textContent = millisToDate(segment.timeSubmitted); |
|
const startColumn = document.createElement('td'); |
|
startColumn.textContent = secondsToTime(segment.startTime); |
|
const endColumn = document.createElement('td'); |
|
endColumn.textContent = secondsToTime(segment.endTime); |
|
const lengthColumn = document.createElement('td'); |
|
lengthColumn.textContent = secondsToTime(segment.endTime - segment.startTime); |
|
const votesColumn = document.createElement('td'); |
|
votesColumn.textContent = segment.votes; |
|
if (segment.votes <= -2) { |
|
votesColumn.appendChild(createSpan('This segment is not sent to users', '❌')); |
|
} |
|
if (segment.locked) { |
|
votesColumn.appendChild(createSpan('This segment is locked by a VIP', '🔒')); |
|
} |
|
getAndAddVipStatus(votesColumn, segment.userID); |
|
const viewsColumn = document.createElement('td'); |
|
viewsColumn.textContent = segment.views; |
|
const categoryColumn = document.createElement('td'); |
|
let hasCategoryColorsScript = false; |
|
[...document.getElementsByTagName('style')].forEach(styleEl => { // Checks if the user has the colored categories script and adds the appropriate classes to the element |
|
[...styleEl.childNodes].forEach(node => { |
|
if (node?.data?.includes('mruy_sbcc')) { |
|
hasCategoryColorsScript = true; |
|
const spanEl = document.createElement('span'); |
|
spanEl.className = `mruy_sbcc mruy_sbcc_${segment.category}`; |
|
if (segment.category !== 'chapter') spanEl.textContent = segment.category; |
|
categoryColumn.appendChild(spanEl); |
|
} |
|
}); |
|
}); |
|
if (segment.category === 'chapter' && hasCategoryColorsScript) { // chapter elements have an additional span in them to have the chapter's text appear in the title. |
|
categoryColumn.firstElementChild.appendChild(createSpan(segment.description, segment.category)); // if category colors script is present another extra span is added and we need to account for that |
|
} else if (segment.category === 'chapter') { |
|
categoryColumn.appendChild(createSpan(segment.description, segment.category)); |
|
} |
|
if (!hasCategoryColorsScript && categoryColumn.childElementCount === 0) categoryColumn.textContent = segment.category; |
|
const shadowhiddenColumn = document.createElement('td'); |
|
shadowhiddenColumn.appendChild(segment.shadowHidden ? createSpan('This segment has been shadowhidden.', '❌') : document.createTextNode('—')); |
|
const uuidColumn = createTextAreaColumnElement(segment.UUID, 'UUID'); |
|
const usernameColumn = document.createElement('td'); |
|
usernameColumn.textContent = '—'; // empty placeholder until username is fetched |
|
getAndAddUsername(usernameColumn, segment.userID); |
|
const actiontypeColumn = document.createElement('td'); |
|
switch (segment.actionType) { |
|
case 'skip': |
|
actiontypeColumn.appendChild(createSpan('Skip', '⏭')); |
|
break; |
|
case 'poi': |
|
actiontypeColumn.appendChild(createSpan('Highlight', '✨️')); |
|
break; |
|
case 'full': |
|
actiontypeColumn.appendChild(createSpan('Full video', '♾')); |
|
break; |
|
case 'mute': |
|
actiontypeColumn.appendChild(createSpan('Mute', '🔇')); |
|
break; |
|
case 'chapter': |
|
actiontypeColumn.appendChild(createSpan('Chapter', '🏷️')); |
|
break; |
|
default: |
|
actiontypeColumn.appendChild(document.createTextNode('—')); |
|
} |
|
const hiddenColumn = document.createElement('td'); |
|
hiddenColumn.appendChild(segment.hidden ? createSpan('This segment is hidden due to video duration change.', '❌') : document.createTextNode('—')); |
|
const userIdColumn = createTextAreaColumnElement(segment.userID, 'UserID'); |
|
row.append(submittedColumn, startColumn, endColumn, lengthColumn, votesColumn, viewsColumn, categoryColumn, shadowhiddenColumn, uuidColumn, usernameColumn, actiontypeColumn, hiddenColumn, userIdColumn); |
|
if (document.getElementById('checkboxSpecial')?.checked) { // Check if the user has Hide Columns script's S/H column |
|
const shColumn = document.createElement('td'); |
|
shColumn.textContent = segment.shadowHidden || segment.hidden ? '❌' : '—'; |
|
row.appendChild(shColumn); |
|
} |
|
return row; |
|
} |
|
|
|
/** |
|
* Creates a span element with a title and text content |
|
* @param {string} title Title for the span |
|
* @param {string} content Text to be put in the span |
|
* @returns {HTMLSpanElement} |
|
*/ |
|
function createSpan(title, content) { |
|
const newSpan = document.createElement('span'); |
|
newSpan.title = title; |
|
newSpan.textContent = content; |
|
return newSpan; |
|
} |
|
|
|
/** |
|
* Creates a td element with a textarea, a button, and a link. |
|
* @param {string} value Value of the textarea |
|
* @param {string} name Name for the textarea. Is the same as the column's header. |
|
* @returns {HTMLTableCellElement} |
|
*/ |
|
function createTextAreaColumnElement(value, name) { |
|
const tdEl = document.createElement('td'); |
|
const textAreaEl = document.createElement('textarea'); |
|
textAreaEl.textContent = value; |
|
textAreaEl.className = 'form-control'; |
|
textAreaEl.name = name; |
|
textAreaEl.readOnly = true; |
|
const buttonEl = document.createElement('button'); |
|
buttonEl.textContent = '✂'; |
|
buttonEl.addEventListener('click', () => GM_setClipboard(value)); |
|
const linkEl = document.createElement('a'); |
|
linkEl.textContent = '🔗'; |
|
linkEl.href = `/${name.toLowerCase()}/${value}/`; |
|
tdEl.append(textAreaEl, buttonEl, linkEl); |
|
return tdEl; |
|
} |
|
|
|
/** |
|
* Checks if the user is a VIP and appends the crown symbol to the given element. |
|
* @param {HTMLTableCellElement} votesColumn The element we want to append the crown symbol to |
|
* @param {string} userID The userID of the user we want to check |
|
*/ |
|
function getAndAddVipStatus(votesColumn, userID) { |
|
GM_xmlhttpRequest({ |
|
method: 'GET', |
|
url: `${BASEAPIURL}/userinfo?publicUserID=${userID}&value=vip`, |
|
responseType: 'json', |
|
timeout: 10000, |
|
onload: (res) => { |
|
if (res.status !== 200) { |
|
console.warn('Couldn\'t get VIP status. API returned status ' + res.status); |
|
appendRetryButtonToColumn(votesColumn, userID, 'Failed to get VIP status. Press to retry', getAndAddVipStatus); |
|
return; |
|
} |
|
if (res.response.vip) { |
|
votesColumn.appendChild(createSpan('This user is a VIP', '👑')); |
|
} |
|
}, |
|
onerror: () => { |
|
console.warn('Couldn\'t get VIP status due to an error'); |
|
appendRetryButtonToColumn(votesColumn, userID, 'Failed to get VIP status. Press to retry', getAndAddVipStatus); |
|
}, |
|
ontimeout: () => { |
|
console.warn('Couldn\'t get VIP status due to timeout'); |
|
appendRetryButtonToColumn(votesColumn, userID, 'Failed to get VIP status. Press to retry', getAndAddVipStatus); |
|
} |
|
}); |
|
} |
|
|
|
/** |
|
* Gets the username of the specified user and adds it to the given element. |
|
* @param {HTMLTableCellElement} usernameColumn The element we want to add the username to |
|
* @param {string} userID The userID of the user whose username we want |
|
*/ |
|
function getAndAddUsername(usernameColumn, userID) { |
|
GM_xmlhttpRequest({ |
|
method: 'GET', |
|
url: `${BASEAPIURL}/userinfo?publicUserID=${userID}&value=userName`, |
|
responseType: 'json', |
|
timeout: 10000, |
|
onload: (res) => { |
|
if (res.status !== 200) { |
|
console.warn('Couldn\'t get username. API returned status ' + res.status); |
|
appendRetryButtonToColumn(usernameColumn, userID, 'Failed to get username. Press to retry', getAndAddUsername); |
|
return; |
|
} |
|
if (res.response.userName === userID) { |
|
return; |
|
} |
|
usernameColumn.replaceWith(createTextAreaColumnElement(res.response.userName, 'Username')); |
|
}, |
|
onerror: () => { |
|
console.warn('Couldn\'t get username due to an error'); |
|
appendRetryButtonToColumn(usernameColumn, userID, 'Failed to get username. Press to retry', getAndAddUsername); |
|
}, |
|
ontimeout: () => { |
|
console.warn('Couldn\'t get username due to timeout'); |
|
appendRetryButtonToColumn(usernameColumn, userID, 'Failed to get username. Press to retry', getAndAddUsername); |
|
} |
|
}); |
|
} |
|
|
|
/** |
|
* Appends a retry button the provided element. |
|
* @param {HTMLTableCellElement} columnEl The element we want to append the retry button to |
|
* @param {string} userID The userID of the user we want to check |
|
* @param {string} btnMsg The message we want to display for the button on hover |
|
* @param {function} functionToCallOnRetry The function to call on retry. The function will be called with columnEl and userID as parameters |
|
*/ |
|
function appendRetryButtonToColumn(columnEl, userID, btnMsg, functionToCallOnRetry) { |
|
let retryBtn = createSpan(btnMsg, ' ↻'); |
|
retryBtn.addEventListener('click', () => { |
|
columnEl.removeChild(retryBtn); |
|
functionToCallOnRetry(columnEl, userID); |
|
}); |
|
retryBtn.style.cursor = 'pointer'; |
|
columnEl.appendChild(retryBtn); |
|
} |
|
|
|
/** |
|
* Fixes/adds the "even" "odd" classes of the table's rows. |
|
* Also Adds display:none to columns that are hidden with the Hide Columns userscript |
|
* @param {HTMLTableSectionElement} tbody Tbody element of the table |
|
*/ |
|
function fixRows(tbody) { |
|
[...tbody.children].forEach((row, i) => { |
|
row.className = i % 2 ? 'odd' : 'even'; |
|
const columnHeaders = [...tbody.parentElement.querySelectorAll('thead th')]; |
|
columnHeaders.forEach((header, j) => { |
|
if (header.style.display === 'none') { |
|
row.children[j].style.display = 'none'; |
|
} |
|
}); |
|
}); |
|
} |
|
|
|
/** |
|
* Adds the refresh button to the page. |
|
*/ |
|
function addRefreshButton() { |
|
const btn = document.createElement('button'); |
|
btn.className = 'btn btn-primary'; |
|
btn.innerText = 'Force Refresh'; |
|
btn.title = 'You can also press the "R" key on your keyboard'; |
|
btn.onclick = () => { |
|
const videoID = checkPageVideoID(); |
|
if (!videoID) return; |
|
getAndUpdateSegments(videoID); |
|
getAndUpdateLocks(videoID); |
|
}; |
|
const referenceElArray = document.getElementsByClassName('col-auto'); |
|
const referenceEl = referenceElArray[referenceElArray.length - 2]; |
|
const parentEl = referenceEl.parentElement; |
|
const copyEl = referenceEl.cloneNode(); |
|
copyEl.appendChild(btn); |
|
parentEl.appendChild(copyEl); |
|
} |
|
|
|
/** |
|
* Gets the ID of the video on the page and returns it if found |
|
* @returns {null | string} |
|
*/ |
|
function checkPageVideoID() { |
|
const pathName = location.pathname.slice(1, -1).split('/'); |
|
let videoID; |
|
if (pathName[0] === 'uuid') { |
|
const url = new URL(document.getElementsByClassName('float-end ms-1')[0]?.href ?? 'https://www.example.com'); |
|
videoID = url.pathname.slice(1, -1).split('/')[1]; |
|
if (videoID?.length === 0) { |
|
window.alert('Could not get the video ID'); |
|
return null; |
|
} |
|
} else { |
|
videoID = pathName[1]; |
|
} |
|
return videoID; |
|
} |
|
})(); |