Greasy Fork 支持简体中文。

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