Skip to content

Instantly share code, notes, and snippets.

@shuckster
Last active May 21, 2023 04:58
Show Gist options
  • Save shuckster/707a852599b226ec8d2591bd32cd663c to your computer and use it in GitHub Desktop.
Save shuckster/707a852599b226ec8d2591bd32cd663c to your computer and use it in GitHub Desktop.
Play a YouTube Playlist in reverse which, for most playlists, means "Play in chronological order".
// ==UserScript==
// @name Play YouTube Playlist in Reverse
// @namespace https://gist.github.com/shuckster
// @downloadURL https://gist.githubusercontent.com/shuckster/707a852599b226ec8d2591bd32cd663c/raw/play-youtube-playlist-in-reverse.user.js
// @updateURL https://gist.githubusercontent.com/shuckster/707a852599b226ec8d2591bd32cd663c/raw/play-youtube-playlist-in-reverse.meta.js
// @version 0.4
// @description Play a YouTube Playlist backwards which, for most playlists, means "play in chronological order".
// @author Conan Theobald
// @match https://www.youtube.com/*
// @grant none
// ==/UserScript==
// ==UserScript==
// @name Play YouTube Playlist in Reverse
// @namespace https://gist.github.com/shuckster
// @downloadURL https://gist.githubusercontent.com/shuckster/707a852599b226ec8d2591bd32cd663c/raw/play-youtube-playlist-in-reverse.user.js
// @updateURL https://gist.githubusercontent.com/shuckster/707a852599b226ec8d2591bd32cd663c/raw/play-youtube-playlist-in-reverse.meta.js
// @version 0.4
// @description Play a YouTube Playlist backwards which, for most playlists, means "play in chronological order".
// @author Conan Theobald
// @match https://www.youtube.com/*
// @grant none
// ==/UserScript==
(function () {
"use strict";
function main(modules) {
const {
statebot: { Statebot },
} = modules;
// State-machine
const bot = Statebot("youtube-playlist-reverser", {
chart: `
idle ->
start-over ->
playing ->
end-of-video ->
idle
`,
startIn: "start-over",
logLevel: 2,
});
bot.performTransitions({
"idle -> start-over": {
on: "initialising-playback",
},
"start-over -> playing": {
on: "metrics-updated",
},
"playing -> end-of-video": {
on: "playback-ending",
then: () => clickPreviousVideoInPlaylist(),
},
});
// Events
watchOn(
videoPlayerElement(),
["loadedmetadata", "durationchange"],
bot.Emit("initialising-playback"),
);
watchOn(
videoPlayerElement(),
[
"abort",
"pause",
"play",
"playing",
"ratechange",
"seeked",
"timeupdate",
],
() => {
bot.emit("metrics-updated");
const nextVideoPlaysInMs = msUntilPlaybackFinished(
videoPlayerElement(),
);
const videoEnding = nextVideoPlaysInMs - 500 < 0;
if (videoEnding) {
bot.emit("playback-ending");
}
},
);
watchOn(videoPlayerElement(), ["ended"], bot.Emit("playback-ending"));
// Actions
function clickPreviousVideoInPlaylist() {
nextAndPreviousPlaylistAnchors().aboveNowPlaying.el.click();
bot.enter("idle");
}
}
//
// Below the fold...
//
function msUntilPlaybackFinished(videoEl) {
if (!videoEl || videoEl.paused) {
return Infinity;
}
const {
duration = Infinity,
currentTime = 0,
playbackRate = 1,
} = videoEl;
const finishedInMs = Math.round(
1000 * ((duration - currentTime) / playbackRate),
);
return !isNaN(finishedInMs) ? finishedInMs : Infinity;
}
// Specific elements
const selectors = {
playlistItems: "ytd-playlist-panel-video-renderer",
selectedPlaylistItem: "ytd-playlist-panel-video-renderer[selected]",
playlistItemIndex: "span#index",
immediateAnchors: "* > a",
videoPlayer: "video",
};
function nextAndPreviousPlaylistAnchors() {
const { immediateAnchors: selLink, playlistItemIndex: selIndex } =
selectors;
const { above, below } = previousAndNextPlaylistItems();
const belowIdx = parseInt(below.querySelector(selIndex)?.textContent, 10);
const aboveIdx = parseInt(above.querySelector(selIndex)?.textContent, 10);
const [_above, _below] = [
{
el: below.querySelector(selLink),
idx: isNaN(belowIdx) ? 1 : belowIdx,
},
{
el: above.querySelector(selLink),
idx: isNaN(aboveIdx) ? -1 : aboveIdx,
},
].sort((a, b) => a.idx - b.idx);
return {
aboveNowPlaying: _above,
belowNowPlaying: _below,
};
}
function previousAndNextPlaylistItems() {
const selectedEl = document.querySelector(selectors.selectedPlaylistItem);
const allEls = Array.from(
document.querySelectorAll(selectors.playlistItems),
);
const [above, selected, below] = adjacents(allEls, selectedEl, 1);
return { above, selected, below };
}
function videoPlayerElement() {
return document.querySelector(selectors.videoPlayer);
}
//
// Helpers
//
// Elements
function watchOn(element, events, fn) {
const [runFn, cancelFn] = makeDebouncer(1, fn);
const eventRemovers = [
cancelFn,
...(events || []).map(eventName => {
element.addEventListener(eventName, runFn);
return () => element.removeEventListener(eventName, runFn);
}),
];
return () => eventRemovers.map(fn => fn());
}
function adjacents(arr, item, numAdjacent) {
const index = arr.indexOf(item);
const startIndex = Math.max(0, index - numAdjacent);
const endIndex = Math.min(arr.length - 1, index + numAdjacent);
return arr.slice(startIndex, endIndex + 1);
}
// Timers
function makeDebouncer(ms, fn) {
let timerId;
const clear = () => clearTimeout(timerId);
const debouncedFn = (...args) => {
clear();
timerId = setTimeout(fn, ms, ...args);
};
return [debouncedFn, clear];
}
function checkPeriodically(intervalInMs, fn) {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(reject, 1000 * 60, "Timed out");
const checkId = setInterval(() => {
const result = fn();
if (result) {
clearTimeout(timeoutId);
clearInterval(checkId);
resolve(result);
}
}, intervalInMs);
});
}
// Loading
function documentLoaded() {
return new Promise(resolve => {
if (document.readyState === "complete") {
return resolve();
}
document.addEventListener(
"readystatechange",
event => {
if (event.target.readyState === "complete") {
resolve();
}
},
{ once: true },
);
});
}
function moduleLoader(global) {
return ({ expectedNamespace, url }) =>
new Promise((resolve, reject) => {
if (global[expectedNamespace]) {
return resolve(global[expectedNamespace]);
}
const el = document.createElement("script");
el.async = true;
el.onerror = reject;
el.onload = () => resolve(global[expectedNamespace]);
el.src = url;
document.body.appendChild(el);
});
}
//
// Entry-point
//
const load = moduleLoader(window);
const waitForPlaylistSelectionToRender = () =>
checkPeriodically(100, () => {
const { selected } = previousAndNextPlaylistItems();
return selected;
});
documentLoaded()
.then(waitForPlaylistSelectionToRender)
.then(selectedPlaylistItem =>
Promise.all([
load({
expectedNamespace: "statebot",
url: "https://unpkg.com/statebot@3.1.3/dist/browser/statebot.min.js",
}),
selectedPlaylistItem,
]),
)
.then(([statebot, selectedPlaylistItem]) => {
// @browser-bug: need to scroll window in order for sub-div to scroll also
window.scrollTo(0, 1);
selectedPlaylistItem.scrollIntoView({
behavior: "smooth",
block: "center",
});
// Start monitoring playback and skipping to previous videos
main({ statebot });
})
.catch(error => {
console.warn(
`Problem loading YouTube Playlist Reverser script: ${error}`,
);
});
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment