Adds support for nexttrack, previoustrack from mediaSession API, as well as shuffle support, for youtube compilation videos with this.currentTrackLists in the description
// ==UserScript== // @name Youtube Compilation Music Controls // @description Adds support for nexttrack, previoustrack from mediaSession API, as well as shuffle support, for youtube compilation videos with this.currentTrackLists in the description // @author Mattwmaster58 <[email protected]> // @namespace Mattwmaster58 Scripts // @match https://www.youtube.com/* // @run-at document-start // @grant GM_registerMenuCommand // @version 0.1 // ==/UserScript== class YCMC { // if we're within this threshold of the track start and a seekPrevious is issued, // we go back to the previous track instead of the start of the current track static TRACK_START_THRESHOLD = 4; static PLAYER_SETUP_QUERY_INTERVAL_MS = 200; // anything this or less many tracks will not be considered a compilation static NOT_A_COMPILATION_THRESHOLD = 3; // if we find this amount of tracks or less, we should continue our search (eg, in the comments) static KEEP_SEARCHING_THRESHOLD = 6; static COMMENT_SEARCH_LIMIT = 10; recentlySeeked; shuffleOn; VIDEO_ID; defaultTrackList; currentTrackList; videoElement; descriptionElement; ogNextHandler; ogPreviousHandler; resetInst() { this.recentlySeeked = this.shuffleOn = false; this.VIDEO_ID = (location.href.match( /(?:youtu\.be\/|youtube\.com(?:\/embed\/|\/v\/|\/watch\?v=|\/user\/\S+|\/ytscreeningroom\?v=|\/sandalsResorts#\w\/\w\/.*\/))([^\/&]{10,12})/ ) || [null, null])[1]; this.defaultTrackList = this.currentTrackList = this.videoElement = this.currentTrack = this.nextTrack = this.ogNextHandler = this.ogPreviousHandler = null; } parseTextForTimings(desc_text) { let tracks = []; const timings = desc_text.matchAll( /^(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?\D[\s\-:]*(.*)\s*/gim ); [...timings].forEach((match, defaultIndex) => { let hh, mm, ss, start; [hh, mm, ss] = padArrayStart( match.slice(1, 4).filter(Boolean), 3, 0 ).map((x) => parseInt(x, 10)); start = (hh || 0) * 60 * 60 + (mm || 0) * 60 + ss; tracks.push({ currentIndex: defaultIndex, defaultIndex, start, title: match[4], }); }); return tracks; } parseFromAnywhere() { let attempts = []; this.descriptionElement = document.querySelector( "#description yt-formatted-string" ); const vidDesc = this.descriptionElement.textContent; _log(`attempted parse of YT description`); attempts.push(this.parseTextForTimings(vidDesc)); // todo: make this trigger a comment loading via scroll events? // comments unloaded by default if (false && attempts[0].length <= YCMC.KEEP_SEARCHING_THRESHOLD) { // don't ask me why there's duplicate IDs for (const [idx, commentElem] of document .querySelectorAll("#contents #content #content-text") .entries()) { if (idx >= YCMC.COMMENT_SEARCH_LIMIT) { break; } _log(`attempted parse of comment ${idx}`); attempts.push(this.parseTextForTimings(commentElem.textContent)); if (attempts[idx + 1].length > YCMC.KEEP_SEARCHING_THRESHOLD) { return attempts[idx + 1]; } } } const max = attempts.reduce((prev, current) => { return prev.length > current.length ? prev : current; }); if (max.length <= YCMC.NOT_A_COMPILATION_THRESHOLD) { _warn( `longest sequence of timestamps found was only ${max.length}, which is < ${YCMC.NOT_A_COMPILATION_THRESHOLD}` ); return []; } else { return max; } } getNowPlaying() { const cur_time = this.videoElement.currentTime; for (const track of this.defaultTrackList || []) { if (track.start > cur_time) { return this.defaultTrackList[ clamp(track.defaultIndex - 1, 0, this.defaultTrackList.length - 1) ]; } } } toggleShuffle() { this.shuffleOn = !this.shuffleOn; if (this.shuffleOn) { _log(`shuffling ${this.currentTrackList.length} tracks`); // https://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array let track_len = this.currentTrackList.length; while (track_len) { let idx = Math.floor(Math.random() * track_len--); let temp = this.currentTrackList[track_len]; this.currentTrackList[track_len] = this.currentTrackList[idx]; this.currentTrackList[idx] = temp; } } else { _log(`unshuffling currently shuffled list`); this.currentTrackList = [...this.defaultTrackList]; } [...this.currentTrackList].forEach((track, idx) => { track.currentIndex = idx; }); } seekTo(track) { if (track) { _log(`seeking to track ${JSON.stringify(track)}`); this.recentlySeeked = true; this.currentTrack = track; this.nextTrack = null; this.videoElement.currentTime = track.start; this.setNowPlaying(track); } else { _warn(`failed to seek. track is undefined`); } } setNowPlaying(track) { let nowPlaying = track || this.getNowPlaying(); _log(`setting up now playing: ${JSON.stringify(nowPlaying)}`); if (nowPlaying?.title) { navigator.mediaSession.metadata = new MediaMetadata({ title: nowPlaying.title, artist: this.channelName, artwork: [ { src: `https://i.ytimg.com/vi/${this.VIDEO_ID}/mqdefault.jpg`, sizes: "320x180", type: "image/jpeg", }, ], }); } } seekNext = (event) => this.seekFromCurrent(1, event); seekPrevious = (event) => this.seekFromCurrent(-1, event); seekFromCurrent(offset, event) { const NEXT = 1, PREVIOUS = -1; _log( `received seek ${ offset === PREVIOUS ? "previous" : "next" } command at ${this.videoElement.currentTime}` ); let now_playing = this.getNowPlaying(); if (now_playing) { // if going in reverse and if ( offset === PREVIOUS && this.videoElement.currentTime - now_playing.start > YCMC.TRACK_START_THRESHOLD ) { offset = 0; } let track = this.currentTrackList[now_playing.currentIndex + offset]; if (!track) { if (offset === PREVIOUS && this.ogPreviousHandler) { this.ogPreviousHandler(event); } else if (offset === NEXT && this.ogNextHandler) { this.ogNextHandler(event); } } this.seekTo(track); } else { _warn( "could not resolve currently playing track, cannot seek relative to it" ); } } setup() { this.defaultTrackList = this.parseFromAnywhere(); this.currentTrackList = [...this.defaultTrackList]; _log(`parsed ${this.defaultTrackList.length} tracks`); if (this.defaultTrackList.length) { GM_registerMenuCommand("shuffle", this.toggleShuffle.bind(this), "s"); this.videoElement = document.querySelector("video"); this.channelName = document .querySelector("#player ~ #meta .ytd-channel-name a") .textContent.trim(); navigator.mediaSession.setActionHandler( "nexttrack", this.seekNext, true ); navigator.mediaSession.setActionHandler( "previoustrack", this.seekPrevious, true ); this.videoElement.addEventListener( "timeupdate", this.timeUpdateHandler.bind(this) ); // in the past we've had a one time listener to update on play // i don't think this is necessary } } timeUpdateHandler() { if (!this.defaultTrackList) { return; } if (!this.currentTrack && !this.nextTrack) { this.setNowPlaying(); } this.currentTrack = this.currentTrack || this.getNowPlaying(); this.nextTrack = this.nextTrack || (this.currentTrack && this.defaultTrackList[this.currentTrack.defaultIndex + 1]); const curTimeAfterTrackStart = this.currentTrack && this.videoElement.currentTime >= this.currentTrack.start; const curTimeBeforeNextTrackStart = (this.currentTrack && this.nextTrack && this.nextTrack.start > this.videoElement.currentTime) || !this.nextTrack; if ( !this.currentTrack || (curTimeAfterTrackStart && curTimeBeforeNextTrackStart) ) { return; } if (this.recentlySeeked) { _log("recently seeked, ignoring player head boundary crossing"); this.recentlySeeked = false; return; } _log( `currentTime ${this.videoElement.currentTime} out of range !(${this.currentTrack.start} <= ${this.videoElement.currentTime} < ${this.nextTrack.start}), updating track info` ); if (this.shuffleOn) { // go to the next track in the shuffled playlist been shuffled _log(`shuffle is currently on, retrieving next track`); let next_shuffled_track = this.currentTrackList[this.currentTrack.currentIndex + 1]; this.seekTo(next_shuffled_track); } else { // otherwise, just let the player progress automatically this.currentTrack = this.getNowPlaying(); this.setNowPlaying(this.currentTrack); } this.nextTrack = null; } waitToSetup() { this.resetInst(); _log("waiting for YT Player to load"); window.setupPoller = window.setInterval(() => { if (!this.VIDEO_ID) { _log("parsing youtube video ID failed, pr###ming non-video page"); window.clearInterval(setupPoller); return; } let descriptionElement = document.querySelector( "#description yt-formatted-string" ); if ( document.querySelector("ytd-watch-flexy") && descriptionElement && descriptionElement !== this.descriptionElement && document.querySelector("video") ) { _log("found player, setting up"); this.setup(); window.clearInterval(setupPoller); } else if (descriptionElement) { // ie, we have all the elements but aren't confident the page has changed const observer = new MutationObserver((mutationsList, observer) => { if (mutationsList.length > 0) { // typically, takes about 30ms (!!) for the whole list of description span's to be added to the DOM // we triple that for safety: we wait 100ms after a childlist mutation happens // if another childlist mutation happens in that time period, // the current timeout is abandoned and replaced with another 100ms if (window.descMutTimeout) { window.clearTimeout(window.descMutTimeout); } window.descMutTimeout = window.setTimeout(() => { _log("found player + description, setting up"); this.setup(); window.descMutTimeout = null; }, 100); } }); observer.observe( document.querySelector("#description yt-formatted-string"), {childList: true} ); // we no longer care about a generic setup poller, watching the description // for changes is more effecient and suffices window.clearInterval(setupPoller); } }, YCMC.PLAYER_SETUP_QUERY_INTERVAL_MS); } hookMediaSessionSetActionHandler() { _log(`hooking mediaSession.setActionHandler`); const oSetActionHandler = window.navigator.mediaSession.setActionHandler.bind( window.navigator.mediaSession ); navigator.mediaSession.setActionHandler = window.navigator.setActionHandler = (action, handler, friendly) => { if (friendly) { _log( `received friendly setActionHandler call ${action} ${handler}` ); return oSetActionHandler(action, handler); } if (action === "nexttrack") { // noinspection EqualityComparisonWithCoercionJS if (this.ogNextHandler != handler) { _log( `set ogNextHandler from ${this.ogNextHandler} to ${handler}` ); } this.ogNextHandler = handler; } else if (action === "previoustrack") { // noinspection EqualityComparisonWithCoercionJS if (this.ogPreviousHandler != handler) { _log( `set ogPreviousHandler from ${this.ogPreviousHandler} to ${handler}` ); } this.ogPreviousHandler = handler; } else { return oSetActionHandler(action, handler); } }; } } function clamp(number, min, max) { return Math.min(max, Math.max(number, min)); } function _log(...args) { return console.log(...["%c[YCMC]", "color: green", ...args]); } function _warn(...args) { return console.log(...["%c[YCMC]", "color: yellow", ...args]); } // https://stackoverflow.com/a/63856062 function padArrayStart(arr, len, padding) { return Array(len - arr.length) .fill(padding) .concat(arr); } let ycmc = new YCMC(); ycmc.hookMediaSessionSetActionHandler(); window.addEventListener("yt-navigate-finish", () => { if (!/^\/watch/.test(location.pathname)) { _log("nav finished, but not onto watch page, ignoring"); return; } ycmc.waitToSetup(); });