Greasy Fork

Greasy Fork is available in English.

哔哩哔哩-随机列表

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

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

// ==UserScript==
// @name         哔哩哔哩-随机列表
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  为哔哩哔哩视频选集添加随机播放按钮,支持随机选择上一个/下一个视频
// @author       xujinkai
// @license      MIT
// @match        *://*.bilibili.com/video/*
// @icon         https://i0.hdslb.com/bfs/static/jinkela/long/images/favicon.ico
// @grant        none
// ==/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__item",
    // 活跃的播放列表项
    ACTIVE_PLAYLIST_ITEM: ".video-pod__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, // 按钮绑定重试延迟
  }

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

  let shuffleButtonAdded = false
  let buttonCheckInterval = null
  let isRandomPlayEnabled = false

  // 随机播放队列相关
  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)
  }

  /**
   * 检查并添加随机播放按钮
   */
  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()
    }
  }

  /**
   * 创建随机播放按钮
   * @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)
  }

  /**
   * 切换随机播放状态
   */
  function toggleRandomPlay() {
    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 = []
        }
      }
    }

    // 不再显示切换提示
    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}`)
      window.location.href = link.href
    } 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:
        // 手动触发随机播放
        if (isRandomPlayEnabled) {
          playNextInQueue()
        }
        break

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

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

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

  /**
   * 查找视频元素
   * @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)
  }
})()