Last active
April 9, 2025 09:14
-
-
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".
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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== |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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/[email protected]/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