marumaru 更換影片 與 歌詞播放延遲

可更換 marumaru 片源為你指定的 youtube 影片,並且提供更精確的校時

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name           marumaru 更換影片 與 歌詞播放延遲
// @namespace      Anong0u0
// @version        0.3.4
// @description    可更換 marumaru 片源為你指定的 youtube 影片,並且提供更精確的校時
// @author         Anong0u0
// @match          https://www.marumaru-x.com/*-song/play-*
// @icon           https://www.google.com/s2/favicons?sz=64&domain=marumaru-x.com
// @grant          GM_setValue
// @grant          GM_getValue
// @run-at         document-start
// @license        Beerware
// ==/UserScript==

const delay = (ms = 0) => new Promise((r)=>{setTimeout(r, ms)})
const waitElementLoad = (elementSelector, selectCount = 1, tryTimes = 1, interval = 0) =>
{
    return new Promise(async (resolve, reject)=>
    {
        let t = 1, result;
        while(true)
        {
            if(selectCount != 1) {if((result = document.querySelectorAll(elementSelector)).length >= selectCount) break;}
            else {if(result = document.querySelector(elementSelector)) break;}

            if(tryTimes>0 && ++t>tryTimes) return reject(new Error("Wait Timeout"));
            await delay(interval);
        }
        resolve(result);
    })
}

const id = GM_getValue("id", {})
const songID = document.URL.split("/").pop()

// ===== 更改片源 ======

if (songID in id) document.querySelectorAll("[data-video-id]").forEach((e)=>e.setAttribute("data-video-id", id[songID]))
const oldLink = document.createElement("div")
const newLink = document.createElement("div")
const linkArea = document.querySelector(".alert")

oldLink.innerHTML = linkArea.innerHTML
oldLink.style.width = "max-content"
newLink.style.width = "max-content"
linkArea.innerHTML=""
linkArea.append(oldLink)
if (songID in id)
{
    oldLink.style["text-decoration"] = "line-through"
    newLink.innerHTML = `替換影片:<a href="https://youtu.be/${id[songID]}" target="_blank">https://youtu.be/${id[songID]}</a>`
    linkArea.append(newLink)
}

const button = document.createElement("div")
button.style = "position: absolute;top: 0;width: 100%;height: 100%;display: flex;align-items: center;margin-left: 8px"
button.innerHTML = `<button type="button" class="btn btn-dark ml-4"><i class="bi bi-arrow-repeat"></i>更換影片</button>`
new ResizeObserver(() => {
    const width = Math.max(getComputedStyle(oldLink).width.match(/[\d.]+/), getComputedStyle(newLink).width.match(/[\d.]+/)) + "px"
    button.style.left = width
    button.style.width = `calc(100% - ${width})`
}).observe(oldLink);
linkArea.append(button)

const origVid = oldLink.querySelector("a").href.split("/").pop()
button.querySelector("button").onclick = ()=>
{
    const res = prompt(`請輸入欲替換的 Youtube影片 的 網址 或 ID\n此歌曲原始ID為: ${origVid}`, origVid)
    if(!res) return;
    const vid = res.length==11 ? res : res.match(/(?<=\/|v=)[A-Za-z0-9_\-]{11}/)?.[0]
    if(!vid)
    {
        alert("youtube網址或ID錯誤,未替換")
        return
    }
    id[songID] = vid
    if (vid == origVid) delete id[songID]
    GM_setValue("id", id)
    location.reload()
}

// ===== 更改延遲 ======

const delayNum = GM_getValue("delayNum", {})
const delayDiv = document.createElement("div")
delayDiv.hidden = true
delayDiv.innerHTML = `
<div class="dropdown-menu delayInput">
  <span class="minus x10"><<</span>
  <span class="minus x1"><</span>
  <div class="s"><input id="delayInput" type="number" value="${delayNum[songID] || 0}" step="0.1"></div>
  <span class="plus x1">></span>
  <span class="plus x10">>></span>
</div>
<style>
.delayInput * {box-sizing: border-box;}
.delayInput {
    display:block;
    overflow:unset;
    min-width: unset;
    width: max-content;
    left: -90% !important;
    top: 100% !important;
    position: absolute;
    z-index: 114514;
    transform: unset !important;
}

.delayInput input {
	font-size: 1rem;
	height: 34px;
	background-color: #fff;
    border: none;
	float: left;
	width: 60px;
	line-height: 32px;
	text-align: center;
	font-family: "helveticaneuecyrbold";
    padding: 0;
}
.delayInput input::-webkit-outer-spin-button,
.delayInput input::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}
.delayInput .s {display:inline}
.delayInput .s::after {
    content: "s";
    font-size: 0.8rem;
    float: left;
    text-align: center;
    line-height: 37px;
    color: #888;
    background-color: #fff;
    right: 34%;
    position: absolute;
    height: 0;
}

.delayInput span {
    line-height: 33px;
    font-size: 16px;
    font-weight: bolder;
    letter-spacing: -5px;
    text-align: center;
    display: block;
    width: 32px;
    float: left;
    height: 34px;
    cursor: pointer;
    transition: all 0.3s;
    padding-right: 4px;
}
.delayInput span:hover {
	background-color: #d5d5d5;
}
</style>
`

const delayInput = delayDiv.querySelector("#delayInput")

for(const np of ["minus", "plus"])
{
    const npNum = np=="plus" ? 1 : -1
    for(const multiple of ["x1", "x10"])
    {
        const multipleNum = multiple=="x10" ? 1 : 0.1
        delayDiv.querySelector(`span.${np}.${multiple}`).onclick = () =>
        {
            delayInput.value = (Number(delayInput.value) + npNum*multipleNum).toFixed(1)
            delayInput.oninput()
        }
    }
}

const timeStore = []
delayInput.oninput = () =>
{
    const value = Number(delayInput.value)
    if (Number.isInteger(value)) delayInput.value = String(value)
    if (songID in id)
    {
        $player.lyrics.forEach((e, i)=>
        {
            e.st = timeStore[i].st-value
            e.et = timeStore[i].et-value
        })
    }
    else
    {
        $player.lyricsEarlyTime = value
    }
    $player.stopLyrics()
    $player.playLyrics()
    const t = GM_getValue("delayNum", {})
    t[songID] = value
    GM_setValue("delayNum", t)
}

(async ()=>
{
    const timeBtns = [...await waitElementLoad("button.dropdown-toggle[data-original-title=歌詞提早設定]", 2, 0, 100)]
    let lastBtn = null;
    timeBtns.forEach((btn)=>btn.addEventListener("click", ()=>
    {
        btn.parentElement.append(delayDiv)
        delayDiv.hidden = !delayDiv.hidden
        if (lastBtn == btn) return
        delayDiv.hidden = false
        lastBtn = btn
    }))
    document.addEventListener("click", (e) =>
    {
        if (!(delayDiv.contains(e.target) || timeBtns.some((btn)=>btn.contains(e.target))))
        {
            delayDiv.hidden = true
        }
    });
    document.querySelectorAll("button.dropdown-toggle[data-original-title=歌詞提早設定] ~ .dropdown-menu").forEach((e)=>e.remove());


    while(typeof $player == 'undefined') await delay(100)
    while($player?.lyrics?.length === 0) await delay(100);
    $player.lyrics.forEach((e, i)=>{timeStore[i] = {st: e.st, et: e.et}})
    delayInput.oninput()
})()