Greasy Fork 还支持 简体中文。

BC: keyboard shortcuts

for Bandcamp and its embeded player: seek, autoseek when changing track, play/pause, go to prev/next release, volume control, pitch control. Designed for those digging through lots of tunes.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         BC: keyboard shortcuts
// @description  for Bandcamp and its embeded player: seek, autoseek when changing track, play/pause, go to prev/next release, volume control, pitch control. Designed for those digging through lots of tunes.
// @namespace    userscript1
// @version      1.4.6
// @grant        GM.xmlHttpRequest
// @connect      bandcamp.com
// @match        https://bandcamp.com/*
// @match        https://*.bandcamp.com/*
// @match        https://*/*
// @license      GPLv3
// ==/UserScript==

(function() {
  'use strict';

  // Key_ settings refer to physical positions as if you had a QWERTY layout, not letters.
  // https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
  const playPause   = 'KeyP';
  const prev        = 'KeyI';
  const next        = 'KeyO';
  const prevAndSeek = 'KeyH';
  const nextAndSeek = 'KeyL';
  const seekBack    = 'KeyJ';
  const seekForward = 'KeyK';
  const prevRelease = 'KeyY';
  const nextRelease = 'KeyU';
  const volDown     = 'Minus';
  const volUp       = 'Equal';
  const speedDown   = 'BracketLeft';
  const speedUp     = 'BracketRight';
  const speedReset  = 'Semicolon';
  const initialSeek = 60;
  const manualSeek  = 30;
  // end configuration


  // only run on *bandcamp.com or bandcamp on a custom domain
  if (!window.location.hostname.endsWith('bandcamp.com')
      && !document.head.querySelector('meta[property="twitter:site"][content="@bandcamp"]') ) {
        return;
  }


  const aud = $('audio');
  // if (!aud) { return; }   // we want to allow next release on non-player pages
  var prevButton, nextButton, playButton;
  $('#customHeaderWrapper')?.insertAdjacentHTML('beforeEnd', `<div id="shortcuts-message" style="position:absolute; text-align: center; left: 0; right: 0;"></div>`);


  window.addEventListener('keydown', (evt) => {
      if (evt.altKey || evt.ctrlKey || evt.metaKey) { return; }

      if ($('.ui-widget-overlay') || document.activeElement.tagName == 'INPUT') {
        // don't run if dialog box is open or we're typing into a field
        return;
      }

      // check every time to allow collection page to work
      if (!findButtons() && (evt.code != nextRelease)) {
        return;
      }

      // console.log(evt.code);  // uncomment to check key codes
      switch(evt.code) {
          case prev:
              prevButton.click();
              scrollEmbedPlayer();
              message('');
              break;
          case prevAndSeek:
              prevButton.click();
              aud.currentTime = initialSeek;
              scrollEmbedPlayer();
              message('');
              break;
          case next:
              nextButton.click();
              scrollEmbedPlayer();
              message('');
              break;
          case nextAndSeek:
              nextButton.click();
              aud.currentTime = initialSeek;
              scrollEmbedPlayer();
              message('');
              break;
          case seekBack:
              if (aud.paused) {
                playButton.click();
              }
              aud.currentTime -= manualSeek;
              break;
          case seekForward:
              if (aud.paused) {
                playButton.click();
              }
              aud.currentTime += manualSeek;
              break;
          case playPause:
              playButton.click();
              if (playPause === 'Space') {
                evt.preventDefault();  // prevent page scroll
              }
              break;
          case nextRelease:
              aud.pause();
              changeRelease(1);
              break;
          case prevRelease:
              aud.pause();
              changeRelease(-1);
              break;
          case volDown:
              aud.volume = Math.max(aud.volume - 0.1, 0).toFixed(2);
              break;
          case volUp:
              aud.volume = Math.min(aud.volume + 0.1, 1).toFixed(2);
              break;
          case speedDown:
            aud.preservesPitch = false;
            aud.playbackRate = Math.max(aud.playbackRate - 0.02, 0.2).toFixed(2);
            message(aud.playbackRate);
            break;
          case speedUp:
            aud.preservesPitch = false;
            aud.playbackRate = Math.min(aud.playbackRate + 0.02, 3).toFixed(2);
            message(aud.playbackRate);
            break;
          case speedReset:
            aud.playbackRate = 1;
            message('');
            break;

      }
  }, false);

  function findButtons() {
    prevButton = $('div.prevbutton') || $('div.prev-icon');
    nextButton = $('div.nextbutton') || $('div.next-icon');
    playButton = $('div.playbutton') || $('div.playpause') || $('div#big_play_button');
    return playButton;
  }

  function scrollEmbedPlayer() {
    $('li.currenttrack')?.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'nearest'});
  }

  function $(s) {
    return document.querySelector(s);
  }

  function message(str) {
    var elm = $('#shortcuts-message');
    if (elm) { elm.textContent = str; }
  }

  async function changeRelease(direction) {
    var path = window.location.pathname;
    if ((path == '/' || path == '/music' || path == '/releases') && direction == 1) {
      // try clicking 2nd item in the discography on the right
      document.querySelectorAll('#discography .trackTitle a')[1]?.click();
      // try clicking 1st item in the grid list
      $('li.music-grid-item a , div.ipCellImage a')?.click();
      return;
    }
    if (!/^\/album|track/.test(path)) { return; }

    var releasesURL;
    var cacheMe = false;
    var labelLink = $('a.back-to-label-link');
    if (labelLink && labelLink.textContent.includes('back to')) {
      releasesURL = new URL(labelLink.href);
    } else {
      releasesURL = new URL('https://' + window.location.hostname + '/music');
      cacheMe = true;
    }

    const lsKey = 'shortcuts-release-data';
    if (cacheMe && lsGet(lsKey)) {
      console.log('fetching release data from cache');
      var releaseLinks = lsGet(lsKey);
    } else {
      console.log('fetching release data from:', releasesURL.href);
      var doc = await fetchDOM(releasesURL.href);
      // get exact href value, a.href after a cross domain fetch adds the wrong domain to /bare/paths
      var releaseLinks = Array.from(
          doc.querySelectorAll('li.music-grid-item a , div.ipCellImage a')).map(a => a.getAttribute('href')
          );

      // bandcamp limits the number of releases in the raw HTML,
      // the rest are available from a data attribute
      var str = doc.querySelector('#music-grid')?.dataset.clientItems;
      if (str) {
        var data = JSON.parse(str);
        var moreLinks = data.map( ({page_url}) => page_url);
        releaseLinks = releaseLinks.concat(moreLinks);
      }
      // console.log(releaseLinks);

      if (cacheMe) {
        lsSet(lsKey, releaseLinks, 1000 * 60 * 10);
      }
    }

    // todo: URL object compare
    var i = releaseLinks.findIndex(e => e.split('?')[0].endsWith(window.location.pathname));
    if (i == -1) {
      var err = 'error: current item not found on /music page';
      console.log(err);
      message(err);
      return;
    }

    var url = releaseLinks[i + direction];
    if (url) {
      if (!url.startsWith('https://')) {
        url = 'https://' + releasesURL.hostname + url;
      }
      // message(`going to ${(i + 1) + direction} of ${releaseLinks.length}`);
      console.log('navigating to:', url);
      window.location = url;
    } else {
      var msg = 'no more releases';
      if (!cacheMe) {
        msg += ' (checked label page)';
      }
      message(msg);
    }
  }

  function lsSet(key, value, ttl) {
    const now = new Date();
    const item = {
      value: value,
      expiry: now.getTime() + ttl,
    };
    localStorage.setItem(key, JSON.stringify(item));
  }

  function lsGet(key) {
    const itemStr = localStorage.getItem(key);
    if (!itemStr) { return null; }
    const item = JSON.parse(itemStr);
    const now = new Date();
    if (now.getTime() > item.expiry) {
      localStorage.removeItem(key);
      return null;
    }
    return item.value;
  }

  const corsFetch = url => {
    return new Promise((resolve, reject) => {
      GM.xmlHttpRequest({
        method: "GET",
        url: encodeURI(url),
        onload: res => resolve(res.responseText),
        onerror: res => reject(res)
      });
    });
  };

  const fetchDOM = async url => {
    const responseText = await corsFetch(url);
    const parser = new DOMParser();
    return parser.parseFromString(responseText, "text/html");
  };

})();