Greasy Fork is available in English.
There are no navigation buttons for some video pages of tutorial series. This script adds missing buttons like Next or Previous.
// ==UserScript== // @name Ctrl+Paint: Add missing Next and Previous buttons // @namespace https://github.com/T1mL3arn // @description There are no navigation buttons for some video pages of tutorial series. This script adds missing buttons like Next or Previous. // @author T1mL3arn // @version 1.2 // @icon https://static1.squarespace.com/static/50a3c190e4b0d12fc9231429/t/50f87f8ce4b0b3f0a2deeb1d/1537054440579/ // @match https://www.ctrlpaint.com/library* // @match https://www.ctrlpaint.com/videos/* // @run-at document-end // @noframes // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM.setValue // @grant GM.getValue // @grant GM.deleteValue // @grant GM.listValues // @license GPLv3 // @homepageURL https://github.com/t1ml3arn-userscript-js/Ctrl-Paint-Add-missing-Next-and-Previous-buttons // @supportURL https://github.com/t1ml3arn-userscript-js/Ctrl-Paint-Add-missing-Next-and-Previous-buttons/issues // ==/UserScript== (() => { // lib section let log = console.log; let err = console.error; function findHeader(elt){ let prev = elt; while(prev = prev.previousSibling) if(prev.tagName == 'H3') return prev; return null; } function mapsListItemsToNames(list){ let items = list.querySelectorAll('li a'); let r###lt = []; items.forEach((item)=>r###lt.push(item.textContent)); return r###lt; } function mapListItemsToLinks(list){ let items = list.querySelectorAll('li a'); let r###lt = []; items.forEach((item)=>r###lt.push(item.href)); return r###lt; } function readSeriesFrom(document){ let lists = document.querySelectorAll('ol, ul'); let r###lts = []; lists.forEach((list)=>{ // skip empty lists if(list.children.length == 0) return; let header = findHeader(list); if(header){ let links = mapListItemsToLinks(list); if(links.length > 0){ let names = mapsListItemsToNames(list); // just to be sure if(names.length != links.length){ throw `Count of links isn\'t equal to count of names\nProblem in ${header.textContent}`; } r###lts.push({ name: header.textContent, videoNames: names, videoLinks: links }); } } }); if(r###lts.length == 0) throw 'There are no tutorial series at all!'; return r###lts; } async function findTutorialSeriesDataForCurrentPage() { const data = await gm.getValue(TUTORIAL_SERIES_KEY, null) if (!data) throw "Cannot find data for tutorial series" let path = window.location.pathname; let videoIndex; let index = data.findIndex((seriesData)=>{ videoIndex = seriesData.videoLinks.findIndex((link)=>path != "/" && link.indexOf(path) != -1) return videoIndex != -1; }); if(index == -1) return null; let seriesData = data[index]; seriesData.currentVideoIndex = videoIndex; return seriesData; } function addButtons(seriesData) { function getButtonHtml(href, label, name) { return `<div class="button-block sqs-block-button" data-block-type="53" style="${btnCss}"> <div class="sqs-block-content"> <div class="sqs-block-button-container--center" data-alignment="center" data-button-size="small"> <a href="${href}" class="sqs-block-button-element--small sqs-block-button-element" data-initialized="true"> ${label} ${name ? `<br> <span style="${videoNameCss}">${name}</span>` : '' } </a> </div> </div> </div>` } function arrayToCss(acc, val, ind){ return ind%2 == 0 ? `${acc}${val}: ` : `${acc}${val} !important; `; } let btnCss = ["flex", "0 1 auto", "align-self", "auto", "margin", "10px"].reduce(arrayToCss, ''); let videoNameCss = "font-size, 11px, text-transform, none, color, #DDD".split(", ").reduce(arrayToCss); let btnContCss = [ "display", "flex", "flex-direction", "row", "flex-wrap", "wrap", "justify-content", "space-around", "align-content", "center", "align-items", "center", "padding", "15px", ].reduce(arrayToCss, ''); let buttonsWrapper = document.createElement('div'); buttonsWrapper.setAttribute('style', btnContCss); let videoBlock = document.querySelector('.sqs-block.embed-block.sqs-block-embed'); if(videoBlock == null) throw 'There is no video block'; videoBlock.insertAdjacentElement('afterend', buttonsWrapper); let names = seriesData.videoNames; let links = seriesData.videoLinks; let index = seriesData.currentVideoIndex; let nextHtml = index+1 < names.length ? getButtonHtml(links[index+1], 'NEXT', names[index+1]) : ''; let prevHtml = index-1 > -1 ? getButtonHtml(links[index-1], 'PREVIOUS', names[index-1]) : ''; buttonsWrapper.insertAdjacentHTML('beforeend', prevHtml); buttonsWrapper.insertAdjacentHTML('beforeend', nextHtml); } function patchSeriesData(seriesDataList) { try { let data = seriesDataList.find((seriesData) => seriesData.name.indexOf('Painting With Color') != -1); let index = data.videoNames.indexOf('Color Constructor Pt.2 Exercises'); data.videoLinks[index] = 'https://www.ctrlpaint.com/videos/color-constructor-pt2-exercises'; } catch (e) { err('Patch-1 error', e); } // remove the first series cause it has next/prev buttons index = seriesDataList.findIndex((seriesData) => seriesData.name.indexOf('Digital Painting 101') != -1); if(index != -1) seriesDataList.splice(index, 1); else throw 'Patch-2 error'; return seriesDataList; } const TUTORIAL_SERIES_KEY = 'tutorial_series_key'; const ETAG_KEY = "etag" let global = this; let gm = {}; try { if(typeof GM != 'undefined') gm = GM; else { gm = {}; gm.info = GM_info; Object.entries({ 'GM_addStyle': 'addStyle', 'GM_deleteValue': 'deleteValue', 'GM_getResourceURL': 'getResourceUrl', 'GM_getValue': 'getValue', 'GM_listValues': 'listValues', 'GM_notification': 'notification', 'GM_openInTab': 'openInTab', 'GM_registerMenuCommand': 'registerMenuCommand', 'GM_setClipboard': 'setClipboard', 'GM_setValue': 'setValue', 'GM_xmlhttpRequest': 'xmlHttpRequest', 'GM_getResourceText': 'getResourceText', }).forEach(([oldKey, newKey]) => { let old = global[oldKey] || window[oldKey]; if(old && (typeof gm[newKey] == 'undefined')){ gm[newKey] = function(...args){ return new Promise((resolve, reject) => { try { resolve(old.apply(global, args)) } catch(e) { reject(e) } }); } } }); } log(` [ ${gm.info.script.name} ] inited Script handler is ${gm.info.scriptHandler} `); } catch (e) { log('ctrlpaint+ inited partialy. Something went wrong.'); } // structure sample let series ={ name: 'First Steps', /* Name of chapter */ videoNames: ['welcome', 'tut01'], /* Names of all videos in this chapter */ videoLinks: ['#', '#'], /* Links to each video in this chapter */ currentVideoIndex: -1 /* Uses to find previous or next video in this chapter */ }; async function fetchLibraryPage() { // with this Cache-Control header I get cache hits const url = 'https://www.ctrlpaint.com/library' let response = await fetch(url, {headers: {"Cache-Control": "max-age=0"} }); if(!response.ok) throw `Cannot fetch library page at ${url}`; return response } async function parseAndStoreTutorialSeriesData(response) { let pageText = await response.text(); let libraryDocument = new DOMParser().parseFromString(pageText, 'text/html'); let tutorialSeries = readSeriesFrom(libraryDocument); tutorialSeries = patchSeriesData(tutorialSeries); await gm.setValue(TUTORIAL_SERIES_KEY, tutorialSeries); await gm.setValue(ETAG_KEY, response.headers.get('ETag')) } (async ()=>{ lastTutorialData = await gm.getValue(TUTORIAL_SERIES_KEY, null); lastETag = await gm.getValue(ETAG_KEY, null) // update all - it is the very first time or the previous version of the script if (lastTutorialData === null || lastETag === null) { const response = await fetchLibraryPage() await parseAndStoreTutorialSeriesData(response) } else if (lastETag !== null) { // ETAG is set, so lets check it const response = await fetchLibraryPage() const currentETag = response.headers.get('etag') // new etag, so I need to parse data again if (lastETag !== currentETag) await parseAndStoreTutorialSeriesData(response) } // check if current page is a VIDEO page if (window.location.pathname.startsWith("/videos/")) { let seriesData = await findTutorialSeriesDataForCurrentPage(); if(seriesData != null) addButtons(seriesData) } })(); })();