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

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

// ==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()
})()