// Changelog 25/11:
// Youtube interface change broke the button, now fixed
// Changelog 24/6:
// Autoscroll delay now is not correlated with number of items in playlist
// Autoscroll now triggers from the start
// Added feedback to buttons
/* jshint esversion: 8 */
// ==UserScript==
// @name Sort Youtube Watch Later by Duration
// @namespace https://gist.github.com/KohGeek/65ad9e0118ee5f5ee484676731bcd092
// @version 1.0.5
// @description As the name implies, sorts youtube watch later by duration
// @author KohGeek
// @license GNU GPLv2
// @match http://*.youtube.com/playlist*
// @match https://*.youtube.com/playlist*
// @require https://gf.qytechs.cn/scripts/374849-library-onelementready-es7/code/Library%20%7C%20onElementReady%20ES7.js
// @grant none
// @run-at document-start
// ==/UserScript==
// Heavily borrowed from many places
// function for triggering mouse events
let fireMouseEvent = (type, elem, centerX, centerY) => {
var evt = document.createEvent("MouseEvents");
evt.initMouseEvent(type, true, true, window, 1, 1, 1, centerX, centerY, false, false, false, false, 0, elem);
elem.dispatchEvent(evt);
};
// https://ghostinspector.com/blog/simulate-drag-and-drop-javascript-casperjs/
let simulateDrag = (elemDrag, elemDrop) => {
// calculate positions
var pos = elemDrag.getBoundingClientRect();
var center1X = Math.floor((pos.left + pos.right) / 2);
var center1Y = Math.floor((pos.top + pos.bottom) / 2);
pos = elemDrop.getBoundingClientRect();
var center2X = Math.floor((pos.left + pos.right) / 2);
var center2Y = Math.floor((pos.top + pos.bottom) / 2);
// mouse over dragged element and mousedown
fireMouseEvent("mousemove", elemDrag, center1X, center1Y);
fireMouseEvent("mouseenter", elemDrag, center1X, center1Y);
fireMouseEvent("mouseover", elemDrag, center1X, center1Y);
fireMouseEvent("mousedown", elemDrag, center1X, center1Y);
// start dragging process over to drop target
fireMouseEvent("dragstart", elemDrag, center1X, center1Y);
fireMouseEvent("drag", elemDrag, center1X, center1Y);
fireMouseEvent("mousemove", elemDrag, center1X, center1Y);
fireMouseEvent("drag", elemDrag, center2X, center2Y);
fireMouseEvent("mousemove", elemDrop, center2X, center2Y);
// trigger dragging process on top of drop target
fireMouseEvent("mouseenter", elemDrop, center2X, center2Y);
fireMouseEvent("dragenter", elemDrop, center2X, center2Y);
fireMouseEvent("mouseover", elemDrop, center2X, center2Y);
fireMouseEvent("dragover", elemDrop, center2X, center2Y);
// release dragged element on top of drop target
fireMouseEvent("drop", elemDrop, center2X, center2Y);
fireMouseEvent("dragend", elemDrag, center2X, center2Y);
fireMouseEvent("mouseup", elemDrag, center2X, center2Y);
}
// To explain what broke in the original code, here is a comment
// The original code targeted the thumbnail for dragging when that is no longer viable
// Additionally, the timestamp is now two elements instead of one, so I fixed that
let sortVideosByLength = (allAnchors, allDragPoints) => {
let videos = [];
for (let j = 0; j < allAnchors.length; j++) {
let thumb = allAnchors[j];
let drag = allDragPoints[j];
let href = thumb.href;
if (href && href.includes("&list=WL&")) {
let timeSpan = thumb.querySelector("#text");
let timeDigits = timeSpan.innerText.trim().split(":").reverse();
var time = parseInt(timeDigits[0]);
if (timeDigits[1]) time += parseInt(timeDigits[1]) * 60;
if (timeDigits[2]) time += parseInt(timeDigits[2]) * 3600;
videos.push({ anchor: drag, time: time, originalIndex: j });
}
}
if (videos.length > 1) {
for (let j = 0; j < videos.length - 1; j++) {
var smallestLength = 864000;
var smallestIndex = -1;
for (var k = j + 1; k < videos.length; k++) {
if (
videos[k].time < videos[j].time &&
videos[k].time < smallestLength
) {
smallestLength = videos[k].time;
smallestIndex = k;
}
}
if (smallestIndex > -1) {
console.log("Drag " + smallestIndex + " to " + j);
var elemDrag = videos[smallestIndex].anchor;
var elemDrop = videos[j].anchor;
simulateDrag(elemDrag, elemDrop);
return j;
}
}
return videos.length;
}
return 0;
}
let autoScroll = async () => {
let element = document.scrollingElement;
let currentScroll = element.scrollTop;
do {
currentScroll = element.scrollTop;
element.scrollTop = element.scrollHeight;
await new Promise((r) => setTimeout(r, loopTime));
} while (currentScroll != element.scrollTop);
}
// There is an inherent limit in how fast you can sort the videos, due to Youtube refreshing
// This limit also applies if you do it manually
// It is also much worse if you have a lot of videos, for every 100 videos, it's about an extra 2-4 seconds, maybe longer
let zeLoop = async () => {
await autoScroll();
let count = document.querySelectorAll("ytd-playlist-video-renderer").length;
let currentMinimum = 0;
while (true) {
let allAnchors = document.querySelectorAll("div#content a#thumbnail.inline-block.ytd-thumbnail");
let allDragPoints = document.querySelectorAll("yt-icon#reorder");
await autoScroll();
try {
currentMinimum = sortVideosByLength(allAnchors, allDragPoints);
} catch (e) {
if (e instanceof TypeError) {
console.log("Problem with loading, waiting a bit more.")
await new Promise((r) => setTimeout(r, loopTime));
currentMinimum = sortVideosByLength(allAnchors, allDragPoints); // If it somehow still dies, waits another full cycle
}
}
if (currentMinimum === count) { // If your document is already partially sorted, this will break the code early
console.log("Sort complete, or you didn't load all the videos. Video sorted: " + currentMinimum);
break;
}
await autoScroll();
}
}
// If the loading time is for some reason hugely inconsistent, you can use this instead to do it one by one
let zeWithoutLoop = () => {
let allAnchors = document.querySelectorAll("div#content a#thumbnail.inline-block.ytd-thumbnail");
let allDragPoints = document.querySelectorAll("yt-icon#reorder");
sortVideosByLength(allAnchors, allDragPoints);
}
/**
* Generate menu container element
*/
let renderContainerElement = () => {
const element = document.createElement('div')
element.className = 'sort-playlist'
element.style.paddingBottom = '16px'
document.querySelector('div.thumbnail-and-metadata-wrapper').append(element)
}
/**
* Generate button element
* @param {function} click - OnClick handler
* @param {String=} label - Button Label
*/
let renderButtonElement = (click = () => {}, label = '') => {
// Create button
const element = document.createElement('button')
element.className = 'style-scope sort-button-wl'
element.innerText = label
element.onclick = click
// Render button
document.querySelector('div.sort-playlist').appendChild(element)
}
let addCssStyle = () => {
const element = document.createElement('style')
element.innerHTML = `
.sort-button-wl {
background-color: #30d030;
border: 1px #a0a0a0;
border-radius: 2px;
padding: 3px;
margin: 3px;
cursor: pointer;
}
.sort-button-wl:active {
background-color: #209020;
}
`
document.head.appendChild(element);
}
// TODO: expose this in GUI
// change this if it takes longer to load on your system
let loopTime = 1500;
(function() {
'use strict';
onElementReady('div.thumbnail-and-metadata-wrapper', false, () => {
renderContainerElement();
addCssStyle();
renderButtonElement(zeLoop,'Sort All');
renderButtonElement(zeWithoutLoop,'Sort One');
})
})();