Greasy Fork

Greasy Fork is available in English.

《闪韵灵境谱面编辑器》同步助手

将谱面快速同步到VR一体机上

当前为 2023-05-04 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         《闪韵灵境谱面编辑器》同步助手
// @namespace    cipher-editor-beatmap-sync
// @version      2.1.3
// @description  将谱面快速同步到VR一体机上
// @author       如梦Nya
// @license      MIT
// @run-at       document-body
// @grant        unsafeWindow
// @grant        GM_info
// @match        https://cipher-editor-cn.picovr.com/*
// @icon         https://cipher-editor-cn.picovr.com/assets/logo-eabc5412.png
// @require      https://code.jquery.com/jquery-3.6.0.min.js
// ==/UserScript==

const $ = window.jQuery;
const syncWebUrl = "http://cmoyuer.gitee.io/ciphermap-sync-helper-sync-web/"
let JSZip = "";

// ================================= 工具类 =================================

/**
 * 数据库操作类
 */
class WebDB {
    constructor() {
        this.db = undefined
    }

    /**
     * 打开数据库
     * @param {string} dbName 数据库名
     * @param {number | undefined} dbVersion 数据库版本
     * @returns 
     */
    open(dbName, dbVersion) {
        let self = this
        return new Promise(function (resolve, reject) {
            const indexDB = unsafeWindow.indexedDB || unsafeWindow.webkitIndexedDB || unsafeWindow.mozIndexedDB
            let req = indexDB.open(dbName, dbVersion)
            req.onerror = reject
            req.onsuccess = function (e) {
                self.db = e.target.result
                resolve(self)
            }
        });
    }

    /**
     * 查出一条数据
     * @param {string} tableName 表名
     * @param {string} key 键名
     * @returns 
     */
    get(tableName, key) {
        let self = this
        return new Promise(function (resolve, reject) {
            let req = self.db.transaction([tableName]).objectStore(tableName).get(key)
            req.onerror = reject
            req.onsuccess = function (e) {
                resolve(e.target.result)
            }
        });
    }

    /**
     * 插入、更新一条数据
     * @param {string} tableName 表名
     * @param {string} key 键名
     * @param {any} value 数据
     * @returns 
     */
    put(tableName, key, value) {
        let self = this
        return new Promise(function (resolve, reject) {
            let req = self.db.transaction([tableName], 'readwrite').objectStore(tableName).put(value, key)
            req.onerror = reject
            req.onsuccess = function (e) {
                resolve(e.target.result)
            }
        });
    }

    /**
     * 关闭数据库
     */
    close() {
        this.db.close()
        delete this.db
    }
}

/**
 * 通用工具类
 */
class Utils {
    /** @type {HTMLIFrameElement | undefined} */
    static _sandBoxIframe = undefined

    /**
     * 创建一个Iframe沙盒
     * @returns {Document}
     */
    static getSandbox() {
        if (!Utils._sandBoxIframe) {
            let id = GM_info.script.namespace + "_iframe"

            // 找ID
            let iframes = $('#' + id)
            if (iframes.length > 0) Utils._sandBoxIframe = iframes[0]

            // 不存在,创建一个
            if (!Utils._sandBoxIframe) {
                let ifr = document.createElement("iframe");
                ifr.id = id
                ifr.style.display = "none"
                document.body.appendChild(ifr);
                Utils._sandBoxIframe = ifr;
            }
        }
        return Utils._sandBoxIframe
    }

    /**
     * 动态添加Script
     * @param {string} url 脚本链接
     * @returns 
     */
    static dynamicLoadJs(url) {
        return new Promise(function (resolve, reject) {
            let ifrdoc = Utils.getSandbox().contentDocument;
            let script = ifrdoc.createElement('script')
            script.type = 'text/javascript'
            script.src = url
            script.onload = script.onreadystatechange = function () {
                if (!this.readyState || this.readyState === "loaded" || this.readyState === "complete") {
                    resolve()
                    script.onload = script.onreadystatechange = null
                }
            }
            ifrdoc.body.appendChild(script)
        });
    }

    /**
     * 将Blob转换为Base64
     * @param {Blob} blob
     * @returns {Promise}
     */
    static blobToBase64(blob) {
        return new Promise(function (resolve, reject) {
            const fileReader = new FileReader();
            fileReader.onload = (e) => {
                resolve(e.target.result)
            }
            fileReader.readAsDataURL(blob)
        })
    }

    /**
     * 数字数组排序
     * @param {[number]} array 
     * @return {[number]}
     */
    static arraySort(array) {
        const rec = (arr) => {
            // 预防数组是空的或者只有一个元素, 当所有元素都大于等于基准值就会产生空的数组
            if (arr.length === 1 || arr.length === 0) { return arr; }
            const left = [];
            const right = [];
            //以第一个元素作为基准值   
            const mid = arr[0];
            //小于基准值的放左边,大于基准值的放右边
            for (let i = 1; i < arr.length; ++i) {
                if (arr[i] < mid) {
                    left.push(arr[i]);
                } else {
                    right.push(arr[i]);
                }
            }
            //递归调用,最后放回数组    
            return [...rec(left), mid, ...rec(right)];
        };
        const res = rec(array);
        res.forEach((n, i) => { array[i] = n; })
        return array
    }
}

/**
 * 同步页接口
 */
class WebSync {
    /** @type {Window | undefined} */
    static _syncWindow = undefined
    static _ready = false

    /**
     * 获取同步页
     * @returns {Promise<Window>}
     */
    static getWindow() {
        return new Promise(function (resolve, reject) {
            let win = WebSync._syncWindow
            if (!win || win.closed) {
                win = window.open(syncWebUrl, null, "height=600,width=400,resizable=0,status=0,toolbar=0,menubar=0,location=0,status=0")
                WebSync._syncWindow = win
                WebSync._ready = false
            }
            if (WebSync._ready) {
                resolve(win)
            } else {
                let timeoutHandle, handle
                timeoutHandle = setTimeout(() => {
                    clearInterval(handle)
                    reject("time out")
                    win.close()
                }, 5000)
                handle = setInterval(() => {
                    if (!WebSync._ready) return
                    clearTimeout(timeoutHandle)
                    resolve(win)
                }, 100)
            }
        })
    }

    /**
     * 关闭同步页
     */
    static closeWindow() {
        if (!WebSync._syncWindow || WebSync._syncWindow.closed) return
        WebSync._syncWindow.close()
    }

    /**
     * 添加任务到同步页
     * @param {{id:string, name:string, base64:string, image:string}} taskInfo 
     */
    static async addTask(taskInfo) {
        let win = await WebSync.getWindow()
        taskInfo.event = "add_ciphermap"
        win.focus()
        win.postMessage(taskInfo, "*")
    }
}

/**
 * 闪韵工具类
 */
class CipherUtils {
    /**
     * 从首页按钮点击事件中获取歌曲信息
     * @param {PointerEvent} e 
     * @return {Promise<Object>}
     */
    static async getSongInfoFromHomeButton(e) {
        // 关闭弹窗
        let mask = e.target.parentNode
        while (true) {
            if (mask.className && mask.id === "basic-menu") {
                mask = $(mask).find(".css-esi9ax")[0]
                mask.click()
                break
            }
            mask = mask.parentNode
            if (!mask) break
        }
        // index
        let index = -1
        {
            let maskList = $(".css-esi9ax")
            for (let i = 0; i < maskList.length; i++) {
                if (mask === maskList[i]) {
                    index = i
                    break
                }
            }
        }
        // 获取谱面信息
        let BLITZ_RHYTHM = await new WebDB().open("BLITZ_RHYTHM")
        try {
            let songsStr = await BLITZ_RHYTHM.get("keyvaluepairs", "persist:songs")
            let songPairs = JSON.parse(JSON.parse(songsStr).byId)
            // 按最后打开时间排序
            let idMap = {}
            let timeList = []
            for (let id in songPairs) {
                let time = songPairs[id].lastOpenedAt
                idMap[time] = id
                timeList.push(time)
            }
            timeList = Utils.arraySort(timeList)
            // 谱面信息
            let songId = idMap[timeList[timeList.length - index - 1]]
            if (!songId) throw "can not find song id"
            return songPairs[songId]
        } catch (err) {
            throw err
        } finally {
            BLITZ_RHYTHM.close()
        }
    }
    /**
     * 从编辑器内获取歌曲信息
     * @return {Promise<Object>}
     */
    static async getSongInfoFromEditPage() {
        let result = window.location.href.match(/id=(\w*)/)
        if (!result) throw "can not find song id"
        let songId = result[1]
        let BLITZ_RHYTHM = await new WebDB().open("BLITZ_RHYTHM")
        try {
            let songsStr = await BLITZ_RHYTHM.get("keyvaluepairs", "persist:songs")
            let songPairs = JSON.parse(JSON.parse(songsStr).byId)
            let songInfo = songPairs[songId]
            if (!songInfo) throw "can not find song id"
            return songInfo
        } catch (err) {
            throw err
        } finally {
            BLITZ_RHYTHM.close()
        }
    }

    /**
     * 获取当前页面类型
     * @returns 
     */
    static getPageType() {
        let url = window.location.href
        let matchs = url.match(/edit\/(\w{1,})/)
        if (!matchs) {
            return "home"
        } else {
            return matchs[1]
        }
    }
}

// ================================= 方法 =================================

/** @type {{id:string, name:string, image:string, base64:string, timer:number}} */
let query_ciphermap_info

/**
 * 初始化
 */
async function initScript() {
    const sandBox = Utils.getSandbox()

    await Utils.dynamicLoadJs("https://cmoyuer.gitee.io/my-resources/js/jszip.min.js")
    JSZip = sandBox.contentWindow.JSZip

    setInterval(
        addSyncButton,
        1000
    )

    window.addEventListener("message", event => {
        /** @type {{event:string}} */
        let data = event.data
        if (!data || !data.event) return
        if (data.event === "syncweb-alive") {
            WebSync._ready = true
        } else if (data.event === "result_ciphermap_zip") {
            if (data.code !== 0 || !query_ciphermap_info || data.data.id !== query_ciphermap_info.id) return
            clearTimeout(query_ciphermap_info.timer)
            Utils.blobToBase64(data.data.blob).then(base64 => {
                query_ciphermap_info.base64 = base64
                WebSync.addTask(query_ciphermap_info)
                query_ciphermap_info = undefined
            }).catch(err => {
                console.error("转换文件格式时出错:", err)
                alert("转换文件格式时出错!")
            })
        }
    })

    window.addEventListener("beforeunload", () => {
        WebSync.closeWindow()
    })
}

/**
 * 添加同步按钮
 */
function addSyncButton() {
    // TODO 修复从edit返回到home时谱面顺序延时排列的问题
    let pageType = CipherUtils.getPageType()

    if (pageType === "home") {
        // 首页按钮
        {
            let btnList = $(".css-onrhul")
            if (btnList.length > 0) {
                let btn = btnList[0]
                let parentNode = $(btn.parentNode)
                if (parentNode.find("#sync-web").length == 0) {
                    let webBtn = $(btn).clone()[0]
                    webBtn.id = "sync-web"
                    webBtn.innerHTML = "同步助手"
                    webBtn.style["margin-left"] = "0"
                    webBtn.style["color"] = "rgb(0, 230, 118)"
                    webBtn.style["border"] = "1px solid rgba(0, 230, 118, 0.5)"
                    webBtn.onclick = () => { WebSync.getWindow().then(win => win.focus()) }
                    parentNode.append(webBtn)
                }
            }
        }

        // 首页谱面更多按钮
        {
            let btnList = $(".css-u4seia")
            for (let i = 0; i < btnList.length; i++) {
                let btn = btnList[i]
                if (btn.attributes.tabindex.value !== "-1") continue
                let parentNode = $(btn.parentNode)
                if (parentNode.find("#btn-sync").length > 0) continue
                // 复制一个按钮
                let btnSync = $(parentNode[0].childNodes[0]).clone()
                btnSync[0].id = "btn-sync"
                // 修改icon
                let svg = btnSync.find("svg")[0]
                svg.attributes.viewBox.value = "0 0 1024 1024"
                let path = btnSync.find("path")[0]
                path.attributes.d.value = "M779.07437 412.216889a18.962963 18.962963 0 0 1 26.737778 2.161778l111.634963 131.356444a18.962963 18.962963 0 0 1-14.449778 31.250963h-50.251852c-13.274074 70.769778-47.407407 136.343704-99.555555 188.491852-139.58637 139.567407-364.980148 141.027556-506.349037 4.361481l-4.437333-4.361481a62.862222 62.862222 0 0 1 86.091851-91.515259l2.787556 2.616889c91.97037 91.97037 241.057185 91.97037 332.98963 0a234.268444 234.268444 0 0 0 59.354074-99.593482h-43.918223a18.962963 18.962963 0 0 1-14.449777-31.250963l111.634963-131.356444a18.962963 18.962963 0 0 1 2.18074-2.161778z m-35.858963-179.749926l4.437334 4.361481a62.862222 62.862222 0 0 1-86.110815 91.51526l-2.787556-2.616889c-91.97037-91.97037-241.038222-91.97037-332.989629 0a234.458074 234.458074 0 0 0-56.149334 89.6l40.732445 0.018963a18.962963 18.962963 0 0 1 14.449778 31.250963l-111.653926 131.337481a18.962963 18.962963 0 0 1-28.899556 0l-111.653926-131.337481a18.962963 18.962963 0 0 1 14.449778-31.250963h52.261926a359.784296 359.784296 0 0 1 97.564444-178.517334c139.567407-139.567407 364.980148-141.027556 506.349037-4.361481z"
                // 修改文字
                btnSync[0].innerHTML = btnSync[0].innerHTML.replace(/>*\W{1,}$/, ">同步")
                // 绑定点击事件
                btnSync[0].onclick = e => {
                    CipherUtils.getSongInfoFromHomeButton(e).then(songInfo => {
                        sendTaskToSyncWeb(songInfo).catch(err => {
                            console.error(err)
                            alert("同步失败!")
                        })
                    }).catch(err => {
                        console.error(err)
                        alert("同步失败!")
                    })
                }
                parentNode.append(btnSync[0])
            }
        }
    } else {
        $("#sync-web").remove()
        $("#btn-sync").remove()
    }

    if (pageType === "download") {
        // 导出页面
        let divList = $(".css-1tiz3p0")
        if (divList.length > 0) {
            if ($("#div-sync").length > 0) return
            let divBox = $(divList[0]).clone()
            divBox[0].id = "div-sync"
            divBox.find(".css-ujbghi")[0].innerHTML = "同步到VR设备"
            divBox.find(".css-1exyu3y")[0].innerHTML = "点击打开同步页面, 在APP打开后, 它会帮你把谱面传输到VR设备上。"
            divBox.find(".css-1y7rp4x")[0].innerText = "同步到VR设备"
            divBox[0].onclick = e => {
                CipherUtils.getSongInfoFromEditPage().then(songInfo => {
                    sendTaskToSyncWeb(songInfo).catch(err => {
                        console.error(err)
                        alert("同步失败!")
                    })
                }).catch(err => {
                    console.error(err)
                    alert("同步失败!")
                })
            }
            $(divList[0].parentNode).append(divBox)
        }
    } else {
        $("#div-sync").remove()
    }
}

/**
 * 添加任务到同步页
 * @param {Object} songRawInfo
 */
async function sendTaskToSyncWeb(songRawInfo) {
    // 拿到谱子的ID
    let songInfo = {
        id: songRawInfo.id,
        name: songRawInfo.name,
        image: "",
        base64: "",
        timer: 0
    }
    // 封面图片
    let imageName = songRawInfo.coverArtFilename
    let BLITZ_RHYTHM_FILES = await new WebDB().open(songRawInfo.officialId ? "BLITZ_RHYTHM-official" : "BLITZ_RHYTHM-files")
    try {
        let imageBlob = await BLITZ_RHYTHM_FILES.get("keyvaluepairs", imageName)
        songInfo.image = await Utils.blobToBase64(imageBlob)
    } catch (err) {
        console.warn("获取封面图失败", err)
        songInfo.image = ""
    } finally {
        BLITZ_RHYTHM_FILES.close()
    }
    // 谱面压缩包
    songInfo.timer = setTimeout(() => {
        console.warn("获取谱面压缩包失败: 编辑器超时未响应")
        query_ciphermap_info = undefined
        alert("获取谱面压缩包失败!")
    }, 5000)
    query_ciphermap_info = songInfo
    unsafeWindow.postMessage({ event: "query_ciphermap_zip", id: songInfo.id })
}

// ================================= 入口 =================================

// 主入口
(function () {
    'use strict'

    initScript()
})()