* Lyric FullScreen Columnizer

It offers full-width and columnized lyric view by keyboard shortcuts on major lyric services. No more scrolling.

Versión del día 10/12/2020. Echa un vistazo a la versión más reciente.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name        * Lyric FullScreen Columnizer
// @name:ja     * Lyric FullScreen Columnizer
// @name:zh-CN  * Lyric FullScreen Columnizer
// @namespace   knoa.jp
// @description It offers full-width and columnized lyric view by keyboard shortcuts on major lyric services. No more scrolling.
// @description:ja 大手歌詞サイトの歌詞を、キーボードショートカットで横幅いっぱいのカラム分け表示にします。もうスクロールは要りません。
// @description:zh-CN 将大型歌词网站的歌词通过键盘快捷键进行横幅最大化的列分显示。不需要再滚动了。
// @include     https://www.google.*/*Lyric*
// @include     https://www.google.*/*%E6%AD%8C%E8%A9%9E*
// @include     https://www.google.*/*%E6%AD%8C%E8%AF%8D*
// @include     https://www.azlyrics.com/lyrics/*
// @include     https://www.lyrics.com/lyric/*
// @include     http*://www.kget.jp/lyric/*
// @include     https://www.uta-net.com/song/*
// @include     https://utaten.com/lyric/*
// @noframes
// @version     2.0.0
// @grant       none
// ==/UserScript==

(function(){
  const SCRIPTID = 'LyricFullScreenColumnizer';
  const SCRIPTNAME = '* Lyric FullScreen Columnizer';
  const DEBUG = false;/*
[update]
Now available on Google, AZLyrics.com, Lyrics.com, 歌詞GET, 歌ネット, UtaTen.

[possible]
lyrics.com はpreなので単語が切れる。br挿入してnormalテキストにすれば解決するが。

[acknowledgement]
This script is originally dedicated to Milky Queen, for singing freely with her guitar playing.
https://twitter.com/milkyqueen_idol
  */
  if(window === top) console.time(SCRIPTID);
  const MS = 1, SECOND = 1000*MS, MINUTE = 60*SECOND, HOUR = 60*MINUTE, DAY = 24*HOUR, WEEK = 7*DAY, MONTH = 30*DAY, YEAR = 365*DAY;
  const sites = {
    google: {
      /* it doesn't detect url with "lyric" or something here, but @include meta tag does */
      url: /^https:\/\/www\.google\.[^/]+\//,
      targets: {
        body: () => $('body'),
        header: () => $('#sfcnt'),
        lyricBody: () => $('[data-lyricid]'),
        parentOfLyricBody: () => elements.lyricBody?.parentNode,
      },
    },
    azlyrics: {
      url: /^https:\/\/www\.azlyrics\.com\/lyrics\//,
      targets: {
        body: () => $('body'),
        header: () => $('.lboard-wrap'),
        lyricBody: () => $('.main-page br + br + div'),
        beforeLyricBody: () => elements.lyricBody?.previousElementSibling,
      },
    },
    lyrics: {
      url: /^https:\/\/www\.lyrics\.com\/lyric\//,
      targets: {
        body: () => $('body'),
        header: () => $('#content-top'),
        lyricBody: () => $('#lyric-body-text'),
        beforeLyricBody: () => elements.lyricBody?.previousElementSibling,
      },
    },
    kget: {
      url: /^https?:\/\/www\.kget\.jp\/lyric\//,
      targets: {
        body: () => $('body'),
        header: () => $('#searchbar-wrap'),
        lyricBody: () => $('#lyric-trunk'),
        beforeLyricBody: () => elements.lyricBody?.previousElementSibling,
      },
    },
    utanet: {
      url: /^https:\/\/www\.uta-net\.com\/song\//,
      targets: {
        body: () => $('body'),
        header: () => $('#global_header'),
        lyricBody: () => $('#kashi_area'),
        parentOfLyricBody: () => elements.lyricBody?.parentNode,
      },
    },
    utaten: {
      url: /^https:\/\/utaten\.com\/lyric\//,
      targets: {
        body: () => $('body'),
        header: () => $('body > header'),
        lyricBody: () => $('.lyricBody'),
        beforeLyricBody: () => elements.lyricBody?.previousElementSibling,
      },
    },
  };
  let site;
  let elements = {};
  const core = {
    initialize: function(){
      elements.html = document.documentElement;
      elements.html.classList.add(SCRIPTID);
      site = core.getSite(sites);
      if(site){
        core.ready();
        core.addStyle('style');
        core.addStyle('style-' + site.key);
      }
    },
    ready: function(){
      core.getTargets(site.targets).then(() => {
        log("I'm ready.");
        core.bindKeys();
      }).catch(e => {
        console.error(`${SCRIPTID}:`, e);
      });
    },
    bindKeys: function(){
      const {body, header, lyricBody, parentOfLyricBody, beforeLyricBody} = elements;
      window.addEventListener('keydown', e => {
        if(['input', 'textarea'].includes(e.target.localName) || e.target.isContentEditable) return;
        if(e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return;
        console.log(SCRIPTID, e.key);
        switch(e.key){
          /* columnize */
          case('1'):
          case('2'):
          case('3'):
          case('4'):
          case('5'):
          case('6'):
          case('7'):
          case('8'):
          case('9'):
            body.classList.add(SCRIPTID);
            if(document.fullscreenElement === null) header.after(lyricBody);
            lyricBody.dataset.columns = e.key;
            e.preventDefault();
            break;
          /* reset to default */
          case('0'):
          case('Escape'):
            body.classList.remove(SCRIPTID);
            if(parentOfLyricBody) parentOfLyricBody.prepend(lyricBody);
            else beforeLyricBody.after(lyricBody);
            delete lyricBody.dataset.columns;
            e.preventDefault();
            break;
          /* browser's fullscreen */
          case('f'):
            if(document.fullscreenElement === null){
              body.classList.add(SCRIPTID);
              lyricBody.requestFullscreen();
              if(lyricBody.dataset.columns === undefined) lyricBody.dataset.columns = '1';
            }
            else document.exitFullscreen();
            e.preventDefault();
            break;
        }
      }, true);
      /* fire the reset event on fullscreen exit */
      window.addEventListener('fullscreenchange', e => {
        if(document.fullscreenElement) return;
        else window.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'}));
      });
    },
    getSite: function(sites){
      Object.keys(sites).forEach(key => sites[key].key = key);
      let key = Object.keys(sites).find(key => sites[key].url.test(location.href));
      if(key === undefined) return log('Doesn\'t match any sites:', location.href);
      else return sites[key];
    },
    getTarget: function(selector, retry = 10, interval = 1*SECOND){
      const key = selector.name;
      const get = function(resolve, reject){
        let selected = selector();
        if(selected === null || selected.length === 0){
          if(--retry) return log(`Not found: ${key}, retrying... (${retry})`), setTimeout(get, interval, resolve, reject);
          else return reject(new Error(`Not found: ${selector.name}, I give up.`));
        }else{
          if(selected.nodeType === Node.ELEMENT_NODE) selected.dataset.selector = key;/* element */
          else selected.forEach((s) => s.dataset.selector = key);/* elements */
          elements[key] = selected;
          resolve(selected);
        }
      };
      return new Promise(function(resolve, reject){
        get(resolve, reject);
      });
    },
    getTargets: function(selectors, retry = 10, interval = 1*SECOND){
      return Promise.all(Object.values(selectors).map(selector => core.getTarget(selector, retry, interval)));
    },
    addStyle: function(name = 'style', d = document){
      if(html[name] === undefined) return;
      if(d.head){
        let style = createElement(html[name]()), id = SCRIPTID + '-' + name, old = d.getElementById(id);
        style.id = id;
        d.head.appendChild(style);
        if(old) old.remove();
      }
      else{
        let observer = observe(d.documentElement, function(){
          if(!d.head) return;
          observer.disconnect();
          core.addStyle(name);
        });
      }
    },
  };
  const html = {
    style: () => `
      <style type="text/css">
        /* maximize lyricBody */
        [data-selector="lyricBody"][data-columns]{
          width: 100vw;
          padding: 2em 0em 2em 2em;
          margin: 0;
          box-sizing: border-box;
        }
        /* columnize */
        [data-selector="lyricBody"][data-columns="1"]{columns: 1}
        [data-selector="lyricBody"][data-columns="2"]{columns: 2}
        [data-selector="lyricBody"][data-columns="3"]{columns: 3}
        [data-selector="lyricBody"][data-columns="4"]{columns: 4}
        [data-selector="lyricBody"][data-columns="5"]{columns: 5}
        [data-selector="lyricBody"][data-columns="6"]{columns: 6}
        [data-selector="lyricBody"][data-columns="7"]{columns: 7}
        [data-selector="lyricBody"][data-columns="8"]{columns: 8}
        [data-selector="lyricBody"][data-columns="9"]{columns: 9}
        /* no distracting elements */
        [data-selector="lyricBody"][data-columns] ~ *{
          display: none;
        }
      </style>
    `,
    'style-google': () => `
      <style type="text/css">
        body.${SCRIPTID} [data-selector="lyricBody"]{
          background: white;/* i don't know why it is required on fullscreen */
        }
        body.${SCRIPTID} [data-selector="lyricBody"] *{
          display: block !important;
          max-height: inherit !important;
        }
        body.${SCRIPTID} [role="contentinfo"]{
          display: block;
        }
      </style>
    `,
    'style-azlyrics': () => `
      <style type="text/css">
        body.${SCRIPTID} [data-selector="lyricBody"]{
          background: rgb(221, 221, 238);/* i don't know why it is required on fullscreen */
        }
        body.${SCRIPTID} .navbar-bottom,
        body.${SCRIPTID} .navbar-bottom ~ div{
          display: block;
        }
      </style>
    `,
    'style-lyrics': () => `
      <style type="text/css">
        body.${SCRIPTID} #main{
          width: 100vw;
          margin: 20px 0 0;
          max-width: 100vw;
          padding: 0;
        }
        body.${SCRIPTID} [data-selector="lyricBody"]{
          white-space: pre-wrap;
          font-family: 'Droid Sans',sans-serif;
          font-weight: 400;
          font-size: 18px;
          line-height: 26px;
          background: white;/* i don't know why it is required on fullscreen */
        }
        body.${SCRIPTID} footer{
          display: block;
        }
      </style>
    `,
    'style-kget': () => `
      <style type="text/css">
        body.${SCRIPTID} [data-selector="lyricBody"]{
          font-size: 123.1%;
          font-family: "Hiragino Mincho ProN", Meiryo, "MS PMincho", serif;
          background: white;/* i don't know why it is required on fullscreen */
        }
        body.${SCRIPTID} [data-selector="lyricBody"] > a{
          display: none;
        }
        body.${SCRIPTID} #footer-wrap{
          display: block;
        }
      </style>
    `,
    'style-utanet': () => `
      <style type="text/css">
        body.${SCRIPTID} [data-selector="lyricBody"]{
          font-size: 15px;
          background: white;/* i don't know why it is required on fullscreen */
        }
        body.${SCRIPTID} #footer_map,
        body.${SCRIPTID} #footer_bottom{
          display: block;
        }
      </style>
    `,
    'style-utaten': () => `
      <style type="text/css">
        body.${SCRIPTID} footer{
          display: block !important;
        }
        body.${SCRIPTID} footer > aside{
          display: none;
        }
        body.${SCRIPTID}{
          background: #343330;
        }
      </style>
    `,
  };
  const $ = function(s, f = undefined){
    let target = document.querySelector(s);
    if(target === null) return null;
    return f ? f(target) : target;
  };
  const $$ = function(s, f = undefined){
    let targets = document.querySelectorAll(s);
    return f ? f(targets) : targets;
  };
  const createElement = function(html = '<div></div>'){
    let outer = document.createElement('div');
    outer.insertAdjacentHTML('afterbegin', html);
    return outer.firstElementChild;
  };
  const log = function(){
    if(typeof DEBUG === 'undefined') return;
    console.log(...log.build(new Error(), ...arguments));
  };
  log.build = function(error, ...args){
    let l = log.last = log.now || new Date(), n = log.now = new Date();
    let line = log.format.getLine(error), callers = log.format.getCallers(error);
    //console.log(error.stack);
    return [SCRIPTID + ':',
      /* 00:00:00.000  */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
      /* +0.000s       */ '+' + ((n-l)/1000).toFixed(3) + 's',
      /* :00           */ ':' + line,
      /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
      /* caller        */ (callers[1] || '') + '()',
      ...args
    ];
  };
  log.formats = [{
      name: 'Firefox Scratchpad',
      detector: /MARKER@Scratchpad/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Console',
      detector: /MARKER@debugger/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Greasemonkey 3',
      detector: /\/gm_scripts\//,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Greasemonkey 4+',
      detector: /MARKER@user-script:/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500,
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Tampermonkey',
      detector: /MARKER@moz-extension:/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 2,
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Chrome Console',
      detector: /at MARKER \(<anonymous>/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/gm),
    }, {
      name: 'Chrome Tampermonkey',
      detector: /at MARKER \(chrome-extension:.*?\/userscript.html\?name=/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1] - 1,
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
    }, {
      name: 'Chrome Extension',
      detector: /at MARKER \(chrome-extension:/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
    }, {
      name: 'Edge Console',
      detector: /at MARKER \(eval/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm),
    }, {
      name: 'Edge Tampermonkey',
      detector: /at MARKER \(Function/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4,
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm),
    }, {
      name: 'Safari',
      detector: /^MARKER$/m,
      getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/
      getCallers: (e) => e.stack.split('\n'),
    }, {
      name: 'Default',
      detector: /./,
      getLine: (e) => 0,
      getCallers: (e) => [],
  }];
  log.format = log.formats.find(function MARKER(f){
    if(!f.detector.test(new Error().stack)) return false;
    //console.log('////', f.name, 'wants', 0/*the exact line number here*/, '\n' + new Error().stack);
    return true;
  });
  core.initialize();
  if(window === top) console.timeEnd(SCRIPTID);
})();