Greasy Fork is available in English.

Youtube Compilation Music Controls

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, presuming 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();
});