Greasy Fork

Greasy Fork is available in English.

哔哩哔哩-列表随机播放

为哔哩哔哩视频选集添加随机播放按钮,支持随机选择上一个/下一个视频,支持URL参数联动

当前为 2025-03-06 提交的版本,查看 最新版本

// ==UserScript==
// @name         哔哩哔哩-列表随机播放
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  为哔哩哔哩视频选集添加随机播放按钮,支持随机选择上一个/下一个视频,支持URL参数联动
// @author       xujinkai
// @license      MIT
// @match        *://*.bilibili.com/video/*
// @icon         https://i0.hdslb.com/bfs/static/jinkela/long/images/favicon.ico
// @grant        none
// @homepage     http://greasyfork.icu/zh-CN/scripts/528944-%E5%93%94%E5%93%A9%E5%93%94%E5%93%A9-%E5%88%97%E8%A1%A8%E9%9A%8F%E6%9C%BA%E6%92%AD%E6%94%BE
// ==/UserScript==

;(() => {
    //===========================================
    // 配置区域 - 可根据需要修改
    //===========================================

    // 键盘快捷键配置
    const KEY_CONFIG = {
        TOGGLE_RANDOM: "s", // 切换随机播放模式
        PLAY_RANDOM: "n", // 手动触发随机播放
        PREV_VIDEO: "[", // 上一个视频
        NEXT_VIDEO: "]", // 下一个视频
    }

    // 选择器配置
    const SELECTORS = {
        // 播放器相关
        VIDEO: [
            ".bilibili-player-video video", // 标准播放器
            ".bpx-player-video-wrap video", // 新版播放器
            "video", // 通用选择器
        ],
        // 控制区域
        CONTROL_AREAS: [".bilibili-player-video-control", ".bpx-player-control-wrap"],
        // 播放器区域
        PLAYER_AREAS: [".bilibili-player-video-wrap", ".bpx-player-video-area"],
        // 上一个/下一个按钮
        PREV_NEXT_BUTTONS: [
            // 旧版播放器
            ".bilibili-player-video-btn-prev",
            ".bilibili-player-video-btn-next",
            // 新版播放器
            ".bpx-player-ctrl-prev",
            ".bpx-player-ctrl-next",
        ],
        // 自动连播按钮
        AUTO_PLAY: ".auto-play",
        // 播放列表项
        PLAYLIST_ITEM: ".video-pod__list .simple-base-item",
        // 活跃的播放列表项
        ACTIVE_PLAYLIST_ITEM: ".video-pod__list .simple-base-item.active",
    }

    // 样式配置
    const STYLES = {
        BILIBILI_BLUE: "#00a1d6",
        BUTTON_MARGIN: "0 8px 0 0", // 按钮右侧间距
    }

    // 时间配置(毫秒)
    const TIMING = {
        BUTTON_CHECK_INTERVAL: 1000, // 按钮检查间隔
        INITIAL_DELAY: 1500, // 初始化延迟
        PLAY_RANDOM_DELAY: 500, // 随机播放延迟
        BUTTON_BIND_RETRY: 2000, // 按钮绑定重试延迟
        URL_PARAM_CHECK_DELAY: 800, // URL参数检查延迟
    }

    // URL参数配置
    const URL_PARAMS = {
        RANDOM: "random", // 随机播放参数名
        ENABLED_VALUE: "1", // 启用随机播放的参数值
    }

    //===========================================
    // 全局变量
    //===========================================

    let shuffleButtonAdded = false
    let buttonCheckInterval = null
    let isRandomPlayEnabled = false
    let urlParamProcessed = false // 标记URL参数是否已处理

    // 随机播放队列相关
    let originalPlaylist = [] // 原始播放列表
    let shuffledPlaylist = [] // 打乱后的播放列表
    let currentPlayIndex = -1 // 当前播放索引

    //===========================================
    // 主要功能实现
    //===========================================

    /**
   * 初始化脚本
   */
    function init() {
        // 添加键盘快捷键监听
        window.addEventListener("keydown", handleKeyDown)

        // 添加媒体按键监听
        setupMediaKeysListener()

        // 使用间隔检查按钮是否存在
        buttonCheckInterval = setInterval(checkAndAddButton, TIMING.BUTTON_CHECK_INTERVAL)

        // 初始尝试添加按钮
        setTimeout(checkAndAddButton, TIMING.INITIAL_DELAY)

        // 监听URL变化
        setupURLChangeListener()

        // 检查URL参数
        setTimeout(checkURLParams, TIMING.URL_PARAM_CHECK_DELAY)
    }

    /**
   * 检查并添加随机播放按钮
   */
    function checkAndAddButton() {
        // 检查是否已经添加了随机按钮,避免重复添加
        if (document.querySelector(".shuffle-btn")) {
            return
        }

        // 查找自动连播按钮容器
        const autoPlayContainer = document.querySelector(SELECTORS.AUTO_PLAY)
        if (autoPlayContainer) {
            createShuffleButton(autoPlayContainer)
            shuffleButtonAdded = true
            console.log("Bilibili Video Playlist Shuffler button added")

            // 成功添加按钮后清除检查间隔
            if (buttonCheckInterval) {
                clearInterval(buttonCheckInterval)
                buttonCheckInterval = null
            }

            // 设置视频播放结束监听
            setupVideoEndListener()

            // 设置上一个/下一个按钮监听
            setupPrevNextButtonsListener()

            // 初始化播放列表
            initializePlaylist()

            // 再次检查URL参数(确保按钮已添加后再处理)
            if (!urlParamProcessed) {
                checkURLParams()
            }
        }
    }

    /**
   * 创建随机播放按钮
   * @param {HTMLElement} autoPlayContainer - 自动连播按钮容器
   */
    function createShuffleButton(autoPlayContainer) {
        // 克隆自动连播按钮作为模板
        const shuffleContainer = autoPlayContainer.cloneNode(true)
        shuffleContainer.className = "shuffle-btn auto-play" // 保持相同的样式类

        // 添加右侧间距
        shuffleContainer.style.margin = STYLES.BUTTON_MARGIN

        // 修改文本和图标
        const textElement = shuffleContainer.querySelector(".txt")
        if (textElement) {
            textElement.textContent = "随机播放"
        }

        // 替换图标为随机播放图标
        const iconElement = shuffleContainer.querySelector("svg")
        if (iconElement) {
            // 清除原有的路径
            while (iconElement.firstChild) {
                iconElement.removeChild(iconElement.firstChild)
            }

            // 添加随机图标的路径
            const iconPath = document.createElementNS("http://www.w3.org/2000/svg", "path")
            iconPath.setAttribute(
                "d",
                "M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm0.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z",
            )
            iconElement.appendChild(iconPath)
        }

        // 确保开关按钮的样式正确
        const switchElement = shuffleContainer.querySelector(".switch-btn")
        if (switchElement) {
            // 确保开关按钮初始状态为off
            switchElement.className = "switch-btn off"
        }

        // 移除原有的点击事件
        const newShuffleContainer = shuffleContainer.cloneNode(true)

        // 将随机按钮添加到自动连播按钮旁边
        const rightContainer = autoPlayContainer.closest(".right")
        if (rightContainer) {
            rightContainer.insertBefore(newShuffleContainer, autoPlayContainer)

            // 添加点击事件
            newShuffleContainer.addEventListener("click", toggleRandomPlay)
        }
    }

    /**
   * 初始化播放列表
   */
    function initializePlaylist() {
        // 获取所有播放列表项
        const playlistItems = Array.from(document.querySelectorAll(SELECTORS.PLAYLIST_ITEM))
        if (playlistItems.length <= 1) return

        // 保存原始播放列表
        originalPlaylist = playlistItems

        // 初始化打乱的播放列表(初始为空,会在开启随机播放时生成)
        shuffledPlaylist = []

        // 找到当前播放的视频索引
        const activeItem = document.querySelector(SELECTORS.ACTIVE_PLAYLIST_ITEM)
        currentPlayIndex = activeItem ? playlistItems.indexOf(activeItem) : 0

        console.log(`Playlist initialized with ${playlistItems.length} items, current index: ${currentPlayIndex}`)
    }

    /**
   * 生成随机播放队列
   */
    function generateShuffledPlaylist() {
        // 确保原始播放列表已初始化
        if (originalPlaylist.length === 0) {
            initializePlaylist()
            if (originalPlaylist.length === 0) return
        }

        // 创建索引数组
        const indices = Array.from({ length: originalPlaylist.length }, (_, i) => i)

        // 获取当前播放的视频索引
        const activeItem = document.querySelector(SELECTORS.ACTIVE_PLAYLIST_ITEM)
        const currentIndex = activeItem ? originalPlaylist.indexOf(activeItem) : -1

        // 从索引数组中移除当前播放的视频索引
        if (currentIndex !== -1) {
            indices.splice(indices.indexOf(currentIndex), 1)
        }

        // Fisher-Yates 洗牌算法打乱剩余索引
        for (let i = indices.length - 1; i > 0; i--) {
            const j = Math.floor(Math.random() * (i + 1))
            ;[indices[i], indices[j]] = [indices[j], indices[i]]
        }

        // 如果当前有播放的视频,将其放在队列最前面
        if (currentIndex !== -1) {
            indices.unshift(currentIndex)
        }

        // 保存打乱后的播放列表
        shuffledPlaylist = indices.map((index) => originalPlaylist[index])
        currentPlayIndex = 0

        console.log("Shuffled playlist generated:", indices)
    }

    /**
   * 切换随机播放状态
   * @param {boolean} [forceState] - 可选,强制设置为指定状态
   */
    function toggleRandomPlay(forceState) {
        // 如果提供了强制状态,则使用它,否则切换当前状态
        if (typeof forceState === 'boolean') {
            isRandomPlayEnabled = forceState
        } else {
            isRandomPlayEnabled = !isRandomPlayEnabled
        }

        // 更新按钮状态
        const shuffleBtn = document.querySelector(".shuffle-btn")
        if (shuffleBtn) {
            // 更新开关按钮的状态
            const switchElement = shuffleBtn.querySelector(".switch-btn")
            if (switchElement) {
                if (isRandomPlayEnabled) {
                    switchElement.className = "switch-btn on"
                    // 生成随机播放队列
                    generateShuffledPlaylist()
                } else {
                    switchElement.className = "switch-btn off"
                    // 清空随机播放队列
                    shuffledPlaylist = []
                }
            } else {
                // 如果没有开关元素,使用颜色来表示状态
                if (isRandomPlayEnabled) {
                    shuffleBtn.style.color = STYLES.BILIBILI_BLUE
                    // 生成随机播放队列
                    generateShuffledPlaylist()
                } else {
                    shuffleBtn.style.color = ""
                    // 清空随机播放队列
                    shuffledPlaylist = []
                }
            }
        }

        // 更新URL参数
        updateURLParam()

        // 不再显示切换提示
        console.log(isRandomPlayEnabled ? "已开启随机播放模式" : "已关闭随机播放模式")
    }

    /**
   * 设置视频播放结束监听
   */
    function setupVideoEndListener() {
        // 监听视频元素变化
        const observer = new MutationObserver(() => {
            const video = findVideoElement()
            if (video && !video._hasEndedListener) {
                video._hasEndedListener = true

                // 监听视频结束事件
                video.addEventListener("ended", () => {
                    console.log("Video ended, checking random play status")
                    if (isRandomPlayEnabled) {
                        setTimeout(playNextInQueue, TIMING.PLAY_RANDOM_DELAY)
                    }
                })

                console.log("Video end listener added")
                observer.disconnect() // 找到并设置监听器后停止观察
            }
        })

        // 开始观察播放器区域
        const playerArea = findFirstElement(SELECTORS.PLAYER_AREAS) || document.body
        observer.observe(playerArea, { childList: true, subtree: true })
    }

    /**
   * 设置媒体按键监听
   */
    function setupMediaKeysListener() {
        // 尝试使用 MediaSession API
        if ("mediaSession" in navigator) {
            navigator.mediaSession.setActionHandler("previoustrack", () => {
                console.log("Media key: previous track")
                if (isRandomPlayEnabled) {
                    playPrevInQueue()
                    return
                }
            })

            navigator.mediaSession.setActionHandler("nexttrack", () => {
                console.log("Media key: next track")
                if (isRandomPlayEnabled) {
                    playNextInQueue()
                    return
                }
            })

            console.log("Media session handlers registered")
        }

        // 监听键盘媒体按键事件
        document.addEventListener(
            "keyup",
            (e) => {
                // 媒体按键通常会触发特殊的keyCode
                if (e.key === "MediaTrackPrevious" || e.key === "MediaTrackNext") {
                    console.log(`Media key pressed: ${e.key}`)
                    if (isRandomPlayEnabled) {
                        e.preventDefault()
                        e.stopPropagation()

                        if (e.key === "MediaTrackPrevious") {
                            playPrevInQueue()
                        } else if (e.key === "MediaTrackNext") {
                            playNextInQueue()
                        }

                        return false
                    }
                }
            },
            true,
        ) // 使用捕获阶段
    }

    /**
   * 设置上一个/下一个按钮监听
   */
    function setupPrevNextButtonsListener() {
        // 监听按钮变化
        const observer = new MutationObserver(() => {
            // 遍历所有可能的按钮选择器
            SELECTORS.PREV_NEXT_BUTTONS.forEach((selector) => {
                const button = document.querySelector(selector)
                if (button && !button._hasRandomListener) {
                    button._hasRandomListener = true

                    // 保存原始的点击处理函数
                    const originalClickHandler = button.onclick

                    // 替换为我们的处理函数
                    button.onclick = function (e) {
                        if (isRandomPlayEnabled) {
                            e.preventDefault()
                            e.stopPropagation()

                            // 根据按钮类型决定播放上一个还是下一个
                            if (selector.includes("prev")) {
                                playPrevInQueue()
                            } else {
                                playNextInQueue()
                            }

                            return false
                        } else if (originalClickHandler) {
                            return originalClickHandler.call(this, e)
                        }
                    }

                    // 如果是div元素,还需要监听click事件
                    if (button.tagName.toLowerCase() === "div") {
                        button.addEventListener("click", (e) => {
                            if (isRandomPlayEnabled) {
                                e.preventDefault()
                                e.stopPropagation()

                                // 根据按钮类型决定播放上一个还是下一个
                                if (selector.includes("prev")) {
                                    playPrevInQueue()
                                } else {
                                    playNextInQueue()
                                }

                                return false
                            }
                        })
                    }

                    console.log(`Button listener added for ${selector}`)
                }
            })
        })

        // 观察播放器控制区域
        const controlAreas = findElements(SELECTORS.CONTROL_AREAS)

        controlAreas.forEach((area) => {
            if (area) observer.observe(area, { childList: true, subtree: true })
        })

        // 如果没有找到控制区域,观察整个播放器
        if (controlAreas.length === 0) {
            const playerArea = findFirstElement(SELECTORS.PLAYER_AREAS) || document.body
            observer.observe(playerArea, { childList: true, subtree: true })
        }

        // 直接尝试一次绑定
        setTimeout(() => {
            observer.disconnect()
            observer.observe(document.body, { childList: true, subtree: true })
        }, TIMING.BUTTON_BIND_RETRY)
    }

    /**
   * 播放队列中的下一个视频
   */
    function playNextInQueue() {
        if (!isRandomPlayEnabled || shuffledPlaylist.length === 0) {
            // 如果随机播放未启用或队列为空,则随机选择一个视频播放
            playRandomVideo()
            return
        }

        // 移动到下一个索引
        currentPlayIndex = (currentPlayIndex + 1) % shuffledPlaylist.length

        // 播放当前索引的视频
        const videoToPlay = shuffledPlaylist[currentPlayIndex]
        if (videoToPlay) {
            playVideo(videoToPlay)
        } else {
            // 如果出现问题,重新生成队列并播放
            generateShuffledPlaylist()
            playNextInQueue()
        }
    }

    /**
   * 播放队列中的上一个视频
   */
    function playPrevInQueue() {
        if (!isRandomPlayEnabled || shuffledPlaylist.length === 0) {
            // 如果随机播放未启用或队列为空,则随机选择一个视频播放
            playRandomVideo()
            return
        }

        // 移动到上一个索引
        currentPlayIndex = (currentPlayIndex - 1 + shuffledPlaylist.length) % shuffledPlaylist.length

        // 播放当前索引的视频
        const videoToPlay = shuffledPlaylist[currentPlayIndex]
        if (videoToPlay) {
            playVideo(videoToPlay)
        } else {
            // 如果出现问题,重新生成队列并播放
            generateShuffledPlaylist()
            playPrevInQueue()
        }
    }

    /**
   * 随机播放一个视频(不使用队列,完全随机)
   */
    function playRandomVideo() {
        const playlistItems = Array.from(document.querySelectorAll(SELECTORS.PLAYLIST_ITEM))
        if (playlistItems.length <= 1) return

        // 获取当前活跃的视频
        const activeItem = document.querySelector(SELECTORS.ACTIVE_PLAYLIST_ITEM)
        const currentIndex = activeItem ? playlistItems.indexOf(activeItem) : -1

        // 随机选择一个不同的索引
        let randomIndex
        do {
            randomIndex = Math.floor(Math.random() * playlistItems.length)
        } while (randomIndex === currentIndex && playlistItems.length > 1)

            console.log(`Playing random video: ${randomIndex + 1}/${playlistItems.length}`)

        // 播放随机选择的视频
        playVideo(playlistItems[randomIndex])
    }

    /**
   * 播放指定的视频
   * @param {HTMLElement} videoElement - 要播放的视频元素
   */
    function playVideo(videoElement) {
        if (!videoElement) return

        // 尝试获取链接并导航
        const link = videoElement.querySelector("a")
        if (link && link.href) {
            console.log(`Navigating to: ${link.href}`)

            // 添加随机播放参数到URL
            const url = new URL(link.href)
            if (isRandomPlayEnabled) {
                url.searchParams.set(URL_PARAMS.RANDOM, URL_PARAMS.ENABLED_VALUE)
            }

            window.location.href = url.toString()
        } else {
            // 如果无法获取链接,尝试模拟点击
            console.log("Simulating click on playlist item")
            videoElement.click()
        }
    }

    /**
   * 播放上一个视频(如果随机模式开启则使用队列)
   */
    function playPrevVideo() {
        if (isRandomPlayEnabled) {
            playPrevInQueue()
        } else {
            // 尝试点击上一个按钮
            const prevButton = findFirstElement(SELECTORS.PREV_NEXT_BUTTONS.filter((s) => s.includes("prev")))
            if (prevButton) {
                prevButton.click()
            }
        }
    }

    /**
   * 播放下一个视频(如果随机模式开启则使用队列)
   */
    function playNextVideo() {
        if (isRandomPlayEnabled) {
            playNextInQueue()
        } else {
            // 尝试点击下一个按钮
            const nextButton = findFirstElement(SELECTORS.PREV_NEXT_BUTTONS.filter((s) => s.includes("next")))
            if (nextButton) {
                nextButton.click()
            }
        }
    }

    /**
   * 处理键盘快捷键
   * @param {KeyboardEvent} event - 键盘事件
   */
    function handleKeyDown(event) {
        // 忽略带有修饰键的按键
        if (event.ctrlKey || event.altKey || event.metaKey) {
            return
        }

        const key = event.key.toLowerCase()

        // 根据按键执行相应操作
        switch (key) {
            case KEY_CONFIG.TOGGLE_RANDOM:
                // 切换随机播放模式
                const shuffleBtn = document.querySelector(".shuffle-btn")
                if (shuffleBtn) {
                    shuffleBtn.click()
                }
                break

            case KEY_CONFIG.PLAY_RANDOM:
                // 手动触发随机播放
                playNextInQueue()
                break

            case KEY_CONFIG.PREV_VIDEO:
                // 播放上一个视频
                playPrevVideo()
                break

            case KEY_CONFIG.NEXT_VIDEO:
                // 播放下一个视频
                playNextVideo()
                break
        }
    }

    //===========================================
    // URL参数相关功能
    //===========================================

    /**
   * 设置URL变化监听
   */
    function setupURLChangeListener() {
        // 使用History API监听URL变化
        const originalPushState = history.pushState
        const originalReplaceState = history.replaceState

        // 重写pushState
        history.pushState = function() {
            originalPushState.apply(this, arguments)
            checkURLParams()
        }

        // 重写replaceState
        history.replaceState = function() {
            originalReplaceState.apply(this, arguments)
            checkURLParams()
        }

        // 监听popstate事件(浏览器前进/后退)
        window.addEventListener('popstate', () => {
            checkURLParams()
        })

        console.log("URL change listeners set up")
    }

    /**
   * 检查URL参数
   */
    function checkURLParams() {
        const url = new URL(window.location.href)
        const randomParam = url.searchParams.get(URL_PARAMS.RANDOM)

        // 标记URL参数已处理
        urlParamProcessed = true

        // 如果存在随机播放参数并且值为启用值
        if (randomParam === URL_PARAMS.ENABLED_VALUE) {
            console.log("Random play parameter detected in URL")

            // 如果按钮已添加,则启用随机播放
            if (document.querySelector(".shuffle-btn")) {
                if (!isRandomPlayEnabled) {
                    console.log("Enabling random play from URL parameter")
                    toggleRandomPlay(true)

                    // 如果有播放列表,则播放随机视频
                    setTimeout(() => {
                        if (document.querySelectorAll(SELECTORS.PLAYLIST_ITEM).length > 1) {
                            playRandomVideo()
                        }
                    }, TIMING.PLAY_RANDOM_DELAY)
                }
            } else {
                // 如果按钮尚未添加,则设置标志以便稍后处理
                console.log("Button not yet added, will enable random play when button is ready")
                urlParamProcessed = false
            }
        }
    }

    /**
   * 更新URL参数
   */
    function updateURLParam() {
        // 获取当前URL
        const url = new URL(window.location.href)

        // 根据随机播放状态设置或移除参数
        if (isRandomPlayEnabled) {
            url.searchParams.set(URL_PARAMS.RANDOM, URL_PARAMS.ENABLED_VALUE)
        } else {
            url.searchParams.delete(URL_PARAMS.RANDOM)
        }

        // 使用replaceState更新URL,不触发页面刷新
        try {
            window.history.replaceState({}, document.title, url.toString())
            console.log(`URL updated: ${url.toString()}`)
        } catch (e) {
            console.error("Failed to update URL:", e)
        }
    }

    //===========================================
    // 辅助函数
    //===========================================

    /**
   * 查找视频元素
   * @returns {HTMLElement|null} 找到的视频元素或null
   */
    function findVideoElement() {
        return findFirstElement(SELECTORS.VIDEO)
    }

    /**
   * 从选择器数组中查找第一个匹配的元素
   * @param {string[]} selectors - 选择器数组
   * @returns {HTMLElement|null} 找到的元素或null
   */
    function findFirstElement(selectors) {
        for (const selector of selectors) {
            const element = document.querySelector(selector)
            if (element) return element
        }
        return null
    }

    /**
   * 从选择器数组中查找所有匹配的元素
   * @param {string[]} selectors - 选择器数组
   * @returns {HTMLElement[]} 找到的元素数组
   */
    function findElements(selectors) {
        const elements = []
        for (const selector of selectors) {
            const found = document.querySelector(selector)
            if (found) elements.push(found)
        }
        return elements
    }

    /**
   * 显示提示信息
   * @param {string} message - 要显示的消息
   */
    function showNotification(message) {
        // 检查是否已存在通知,如果存在则移除
        const existingNotification = document.querySelector(".shuffle-notification")
        if (existingNotification) {
            document.body.removeChild(existingNotification)
        }

        const notification = document.createElement("div")
        notification.className = "shuffle-notification"
        notification.textContent = message
        notification.style.position = "fixed"
        notification.style.top = "50%"
        notification.style.left = "50%"
        notification.style.transform = "translate(-50%, -50%)"
        notification.style.padding = "10px 20px"
        notification.style.backgroundColor = "rgba(0, 0, 0, 0.7)"
        notification.style.color = "white"
        notification.style.borderRadius = "4px"
        notification.style.zIndex = "9999"

        document.body.appendChild(notification)

        // 淡出动画
        setTimeout(() => {
            notification.style.transition = "opacity 1s ease"
            notification.style.opacity = "0"
            setTimeout(() => {
                if (document.body.contains(notification)) {
                    document.body.removeChild(notification)
                }
            }, 1000)
        }, 1500)
    }

    //===========================================
    // 初始化
    //===========================================

    // 页面加载完成后初始化
    if (document.readyState === "complete") {
        init()
    } else {
        window.addEventListener("load", init)
    }
})()