91 Plus M

打造91譜的最佳體驗

安装此脚本?
作者推荐脚本

您可能也喜欢91 Plus

安装此脚本
// ==UserScript==
// @name         91 Plus M
// @namespace    https://github.com/DonkeyBear
// @version      1.0.2
// @description  打造91譜的最佳體驗
// @author       DonkeyBear
// @match        *://www.91pu.com.tw/m/*
// @match        *://www.91pu.com.tw/song/*
// @icon         https://www.91pu.com.tw/icons/favicon-32x32.png
// @antifeature  tracking
// @grant        none
// ==/UserScript==

/* global $ */

/** 若樂譜頁面為電腦版,跳轉至行動版 */
function redirect () {
  const currentUrl = window.location.href;
  if ((/\/song\//).test(currentUrl)) {
    const sheetId = currentUrl.match(/(?<=\/)\d+(?=\.)/)[0];
    const newUrl = `https://www.91pu.com.tw/m/tone.shtml?id=${sheetId}`;
    window.location.replace(newUrl);
  }
}

/** 引入 Google Analytics */
function injectGtag () {
  const newScript = document.createElement('script');
  newScript.src = 'https://www.googletagmanager.com/gtag/js?id=G-JF4S3HZY31';
  newScript.async = true;
  document.head.appendChild(newScript);
  newScript.onload = () => {
    // 此區塊由 Google Analytics 生成
    window.dataLayer = window.dataLayer || [];
    function gtag () { window.dataLayer.push(arguments) }
    gtag('js', new Date());
    gtag('config', 'G-JF4S3HZY31');
  };
}

/** 注入頁面樣式 */
function injectStyle () {
  const stylesheet = /* css */`
    html {
      background: #fafafa url(/templets/pu/images/tone-bg.gif); 
    }

    header {
      background-color: rgba(25, 20, 90, 0.5);
      backdrop-filter: blur(5px) saturate(80%);
      -webkit-backdrop-filter: blur(5px) saturate(80%);
      display: flex;
      justify-content: center;
      font-family: system-ui;
    }

    header > .set {
      width: 768px;
    }

    .tfunc2 {
      margin: 10px;
    }

    .setint {
      border-top: 1px solid rgba(255, 255, 255, 0.2);
    }

    .setint,
    .plays .capo {
      display: flex;
      justify-content: space-between;
    }

    #mtitle {
      font-family: system-ui;
    }

    .setint {
      border-top: 0;
      padding: 10px;
    }

    .setint > .hr {
      margin-right: 15px;
      padding: 0 15px;
    }

    .capo-section {
      flex-grow: 1;
      margin-right: 0 !important;
      display: flex !important;
      justify-content: space-between !important;
    }

    .capo-button.decrease {
      padding-right: 20px;
    }

    .capo-button.increase {
      padding-left: 20px;
    }

    /* 需要倒數才能關閉的蓋版廣告 */
    #viptoneWindow.window,
    /* 在頁面最底部的廣告 */
    #bottomad,
    /* 最上方提醒升級VIP的廣告 */
    .update_vip_bar,
    /* 譜上的LOGO和浮水印 */
    .wmask,
    /* 彈出式頁尾 */
    footer,
    /* 自動滾動頁面捲軸 */
    .autoscroll,
    /* 頁首的返回列 */
    .backplace,
    /* 頁首的多餘列 */
    .set .keys,
    .set .plays,
    .set .clear,
    /* 功能列上多餘的按鈕 */
    .setint .hr:nth-child(4),
    .setint .hr:nth-child(5),
    .setint .hr:nth-child(6),
    /* 其餘的Google廣告 */
    .adsbygoogle {
      display: none !important;
    }
  `;
  const style = document.createElement('style');
  style.innerText = stylesheet;
  document.head.appendChild(style);
}

/**
 * @typedef {object} Params
 * @prop {number} transpose
 * @prop {boolean} darkMode
 */
/**
 * 從 URL 取得參數
 * @returns {Params}
 */
function getQueryParams () {
  const url = new URL(window.location.href);
  const params = {
    transpose: +url.searchParams.get('transpose'),
    darkMode: !!url.searchParams.get('darkmode')
  };
  return params;
}

/** 用於操作和弦字串 */
class Chord {
  static sharps = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
  static flats = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];

  /** @param {string} chordString  */
  constructor (chordString) {
    this.chordString = chordString;
  }

  /** @param {number} delta */
  transpose (delta) {
    this.chordString = this.chordString.replaceAll(/[A-G][#b]?/g, (note) => {
      const isSharp = Chord.sharps.includes(note);
      const scale = isSharp ? Chord.sharps : Chord.flats;
      const noteIndex = scale.indexOf(note);
      const transposedIndex = (noteIndex + delta + 12) % 12;
      const transposedNote = scale[transposedIndex];
      return transposedNote;
    });
    return this;
  }

  switchModifier () {
    this.chordString = this.chordString.replaceAll(/[A-G][#b]/g, (note) => {
      const scale = note.includes('#') ? Chord.sharps : Chord.flats;
      const newScale = note.includes('#') ? Chord.flats : Chord.sharps;
      const noteIndex = scale.indexOf(note);
      return newScale[noteIndex];
    });
    return this;
  }

  useSharpModifier () {
    this.chordString = this.chordString.replaceAll(/[A-G]b/g, (note) => {
      const noteIndex = Chord.flats.indexOf(note);
      return Chord.sharps[noteIndex];
    });
    return this;
  }

  useFlatModifier () {
    this.chordString = this.chordString.replaceAll(/[A-G]#/g, (note) => {
      const noteIndex = Chord.sharps.indexOf(note);
      return Chord.flats[noteIndex];
    });
    return this;
  }

  toString () {
    return this.chordString;
  }

  toFormattedString () {
    return this.chordString.replaceAll(/[#b]/g, /* html */`<sup>$&</sup>`); // eslint-disable-line quotes
  }
}

/** 用於修改樂譜 */
class ChordSheetElement {
  /** @param {HTMLElement} chordSheetElement  */
  constructor (chordSheetElement) {
    this.chordSheetElement = chordSheetElement;
  }

  formatUnderlines () {
    const underlineEl = this.chordSheetElement.querySelectorAll('u');
    const doubleUnderlineEl = this.chordSheetElement.querySelectorAll('abbr');
    underlineEl.forEach((el) => { el.innerText = `{_${el.innerText}_}` });
    doubleUnderlineEl.forEach((el) => { el.innerText = `{=${el.innerText}=}` });
    return this;
  }

  unformatUnderlines () {
    const underlineEl = this.chordSheetElement.querySelectorAll('u');
    const doubleUnderlineEl = this.chordSheetElement.querySelectorAll('abbr');
    const unformat = (nodeList) => {
      nodeList.forEach((el) => {
        el.innerHTML = el.innerText
          .replaceAll(/{_|{=|=}|_}/g, '')
          .replaceAll(/[a-zA-Z0-9#/]+/g, /* html */`<span class="tf">$&</span>`); // eslint-disable-line quotes
      });
    };
    unformat(underlineEl);
    unformat(doubleUnderlineEl);
    return this;
  }
}

/** 用於取得樂譜相關資訊 */
class ChordSheetDocument {
  constructor () {
    this.el = {
      mtitle: document.getElementById('mtitle'),
      tkinfo: document.querySelector('.tkinfo'),
      capoSelect: document.querySelector('.capo .select'),
      tinfo: document.querySelector('.tinfo'),
      tone_z: document.getElementById('tone_z')
    };
  }

  getId () {
    const urlParams = new URLSearchParams(window.location.search);
    return Number(urlParams.get('id'));
  }

  getTitle () {
    return this.el.mtitle.innerText.trim();
  }

  getKey () {
    const match = this.el.tkinfo?.innerText.match(/(?<=原調:)\w*/);
    return match ? match[0].trim() : '';
  }

  getPlay () {
    const match = this.el.capoSelect?.innerText.split(/\s*\/\s*/);
    return match ? match[1].trim() : '';
  }

  getCapo () {
    const match = this.el.capoSelect?.innerText.split(/\s*\/\s*/);
    return match ? Number(match[0]) : 0;
  }

  getSinger () {
    const match = this.el.tinfo?.innerText.match(/(?<=演唱:).*(?=\n|$)/);
    return match ? match[0].trim() : '';
  }

  getComposer () {
    const match = this.el.tinfo?.innerText.match(/(?<=曲:).*?(?=詞:|$)/);
    return match ? match[0].trim() : '';
  }

  getLyricist () {
    const match = this.el.tinfo?.innerText.match(/(?<=詞:).*?(?=曲:|$)/);
    return match ? match[0].trim() : '';
  }

  getBpm () {
    const match = this.el.tkinfo?.innerText.match(/\d+/);
    return match ? Number(match[0]) : 0;
  }

  getSheetText () {
    const formattedChordSheet = this.el.tone_z.innerText
      .replaceAll(/\s+?\n/g, '\n')
      .replaceAll('\n\n', '\n')
      .trim()
      .replaceAll(/\s+/g, (match) => { return `{%${match.length}%}` });
    return formattedChordSheet;
  }
}

/**
 * 將 Header 和譜上的和弦移調,並實質修改於 DOM
 * @param {number} delta
 */
function transposeSheet (delta) {
  // 修改 Header 上的 Capo
  const $spanCapo = $('.capo-button > .text-capo');
  const newSpanCapoText = (+$spanCapo.text() + delta) % 12;
  $spanCapo.text(newSpanCapoText);

  // 修改 Header 上的 Key
  const $spanKey = $('.capo-button > .text-key');
  const keyName = new Chord($spanKey.text());
  const newSpanCapoHTML = keyName.transpose(-delta).toFormattedString();
  $spanKey.html(newSpanCapoHTML);

  // 修改譜上的和弦
  $('#tone_z .tf').each(function () {
    const chord = new Chord($(this).text());
    const newChordHTML = chord.transpose(-delta).toFormattedString();
    $(this).html(newChordHTML);
  });
};

/** 初始化並綁定大部分事件 */
function initEventHandlers () {
  /** @type {number} */
  let originalCapo;

  // 頁面動態讀取完成時觸發
  $('body').on('mutation.done', () => {
    // 記錄原調
    const $textCapo = $('.capo-button > .text-capo');
    originalCapo = +$textCapo.text();

    // 依照 URL 參數進行移調
    if (getQueryParams().transpose) {
      transposeSheet(getQueryParams().transpose);
    }
  });

  // 點擊移調按鈕時進行移調
  $('body').on('click', '.capo-section > .capo-button.decrease', () => { transposeSheet(-1) });
  $('body').on('click', '.capo-section > .capo-button.increase', () => { transposeSheet(1) });
  $('body').on('click', '.capo-section > .capo-button.info', () => {
    const $textCapo = $('.capo-button > .text-capo');
    const currentCapo = +$textCapo.text();
    transposeSheet(originalCapo - currentCapo);
  });
}

/**
 * 將網頁標題替換為自訂格式
 * @returns {boolean} 是否完成
 */
function changeTitle () {
  const $mtitle = $('#mtitle');
  const newTitle = $mtitle.text().trim();
  if (newTitle) {
    document.title = `${newTitle} | 91+ M`;
    return true;
  } else {
    return false;
  }
}

/**
 * 修改 Header:替換移調按鈕、增加自訂按鈕等
 * @returns {boolean} 是否完成
 */
function modifyHeader () {
  const capoSelectText = $('.capo .select').eq(0).text().trim();
  if (!capoSelectText) { return false }

  const stringCapo = capoSelectText.split(/\s*\/\s*/)[0]; // CAPO
  const stringKey = capoSelectText.split(/\s*\/\s*/)[1]; // 調

  // 新增功能鈕
  const newFunctionDiv = document.createElement('div');
  newFunctionDiv.classList.add('hr', 'capo-section');
  newFunctionDiv.innerHTML = /* html */`
    <button class="scf capo-button decrease">◀</button>
    <button class="scf capo-button info">
      CAPO:<span class="text-capo">${stringCapo}</span>
      (<span class="text-key">${
        stringKey.replaceAll(/[#b]/g, /* html */`<sup>$&</sup>`) // eslint-disable-line quotes
      }</span>)
    </button>
    <button class="scf capo-button increase">▶</button>
  `;
  document.querySelector('.setint').appendChild(newFunctionDiv);

  return true;
}

/**
 * 發送請求至 API,雲端備份樂譜
 * @returns {boolean} 是否完成
 */
function archiveChordSheet () {
  const sheet = document.getElementById('tone_z');
  if (!sheet?.innerText.trim()) { return false }

  const chordSheetDocument = new ChordSheetDocument();
  try {
    const chordSheetElement = new ChordSheetElement(sheet);
    chordSheetElement.formatUnderlines();

    const formBody = {
      id: chordSheetDocument.getId(),
      title: chordSheetDocument.getTitle(),
      key: chordSheetDocument.getKey(),
      play: chordSheetDocument.getPlay(),
      capo: chordSheetDocument.getCapo(),
      singer: chordSheetDocument.getSinger(),
      composer: chordSheetDocument.getComposer(),
      lyricist: chordSheetDocument.getLyricist(),
      bpm: chordSheetDocument.getBpm(),
      sheet_text: chordSheetDocument.getSheetText()
    };
    chordSheetElement.unformatUnderlines();

    fetch('https://91-plus-plus-api.fly.dev/archive', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(formBody)
    })
      .then(response => { console.log('雲端樂譜備份成功:', response) })
      .catch(error => { console.error('雲端樂譜備份失敗:', error) });
  } catch {
    console.warn('樂譜解析失敗,無法備份');
    fetch(`https://91-plus-plus-api.fly.dev/report?id=${chordSheetDocument.getId()}`);
  }

  return true;
}

/**
 * @typedef {object} ObserverCheckList
 * @prop {boolean} changeTitle 是否已替換頁面標題
 * @prop {boolean} modifyHeader 是否已替換 Header
 * @prop {boolean} archiveChordSheet 是否已將樂譜進行雲端備份
 */
/**
 * 透過 MutationObserver 觸發的處理函式
 * @param {ObserverCheckList} checkList
 */
function observerHandler (checkList) {
  if (!checkList.changeTitle) {
    checkList.changeTitle = changeTitle();
  }
  if (!checkList.modifyHeader) {
    checkList.modifyHeader = modifyHeader();
  }
  if (!checkList.archiveChordSheet) {
    checkList.archiveChordSheet = archiveChordSheet();
  }

  // 如果已全數完成,則觸發 body 上的 mutation.done 事件
  let isAllClear = true;
  for (const checked of Object.values(checkList)) {
    if (!checked) { isAllClear = false }
  }
  if (isAllClear) { $('body').trigger('mutation.done') }
}

/** 初始化 MutationObserver */
function initMutationObserver () {
  /** @type {ObserverCheckList} */
  const observerCheckList = {
    changeTitle: false,
    modifyHeader: false,
    archiveChordSheet: false
  };

  const observer = new MutationObserver(() => {
    observerHandler(observerCheckList);
  });
  observer.observe(document.body, { childList: true, subtree: true });
  $('body').on('mutation.done', () => { observer.disconnect() });
}

/** 於每天第一次使用時跳出升級建議 */
function askToUpdate () {
  const currentUrl = window.location.href;
  if ((/\/song\//).test(currentUrl)) { return }

  const storageKey = 'plus91-last-visit';
  const lastVisit = localStorage.getItem(storageKey);
  const formatDate = (date) => {
    const year = date.getFullYear();
    const month = date.getMonth() + 1;
    const day = date.getDate();
    return `${year}-${month}-${day}`;
  };
  const currentDate = formatDate(new Date());
  if (currentDate !== lastVisit) {
    localStorage.setItem(storageKey, currentDate);
    const ans = confirm('91 Plus M 已經停止更新和維護了,\n建議升級至全新版本的 91 Plus!\n\n(本訊息僅會在每天第一次使用時跳出)');
    if (ans) {
      window.location.replace('https://github.com/DonkeyBear/91Plus/wiki/91-Plus-%E8%88%87-91-Plus-M');
    }
  }
}

/** 主程式進入點 */
function main () {
  redirect();
  injectGtag();
  injectStyle();
  initEventHandlers();
  initMutationObserver();
  askToUpdate();
}

main();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址