// ==UserScript==
// @name B站(哔哩哔哩)增强工具箱 - 随机播放、自定义倍速、自动播放/暂停、网页全屏
// @namespace http://tampermonkey.net/
// @version 1.4
// @description B站增强工具箱:自动播放/暂停、随机播放、自定义倍速、网页全屏等功能
// @author xujinkai
// @license MIT
// @match *://www.bilibili.com/*
// @icon https://i0.hdslb.com/bfs/static/jinkela/long/images/favicon.ico
// @grant none
// ==/UserScript==
; (() => {
//===========================================
// 配置区域 - 可根据需要修改
//===========================================
const CONFIG = {
autoPlay: true, // 打开页面时自动播放视频
autoPause: true, // 自动暂停其他标签页的视频
randomPlayButton: true, // 随机播放的按钮
// 播放速度选项(从慢到快排序)
// 这些倍速会自动绑定到字母上方的1到0十个数字
playbackRates: ["0.2", "0.75", "1.0", "1.5", "2.0", "2.5", "3.0", "4.0", "5.0", "6.0"],
// 键盘快捷键配置
keys: {
// 随机播放相关
toggleRandom: "s", // 切换随机播放模式
playRandom: "n", // 手动触发随机播放
prevVideo: "[", // 上一个视频
nextVideo: "]", // 下一个视频
// 倍速相关
decreaseSpeed: ",", // 减速
increaseSpeed: ".", // 加速
resetSpeed: "/", // 恢复原速
// 全屏相关
webFullscreen: "g", // 网页全屏
// 中键点击播放器所映射的按键
middleClick: "g",
},
// URL参数配置
urlParams: {
random: "random", // 随机播放参数名
enabledValue: "1", // 启用随机播放的参数值
},
// 时间配置(毫秒)
timing: {
buttonCheckInterval: 1000, // 按钮检查间隔
initialDelay: 1500, // 初始化延迟
playRandomDelay: 500, // 随机播放延迟
buttonBindRetry: 2000, // 按钮绑定重试延迟
urlParamCheckDelay: 800, // URL参数检查延迟
updateInterval: 1000, // 更新检查间隔
},
// 样式配置
styles: {
bilibiliBlue: "#00a1d6", // B站蓝色
buttonMargin: "0 8px 0 0", // 按钮右侧间距
},
}
//===========================================
// 常量定义
//===========================================
// 选择器常量
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",
// 播放速度菜单
PLAYBACK_RATE_MENU: ".bpx-player-ctrl-playbackrate-menu",
// 网页全屏按钮
WEB_FULLSCREEN_BUTTON: ".bpx-player-ctrl-web",
}
// 事件类型常量
const EVENTS = {
PLAY: "play",
ENDED: "ended",
KEYDOWN: "keydown",
KEYUP: "keyup",
MOUSEDOWN: "mousedown",
CLICK: "click",
POPSTATE: "popstate",
LOAD: "load",
}
// 自定义事件类型
const CUSTOM_EVENTS = {
VIDEO_PLAY: "video:play",
VIDEO_PAUSE: "video:pause",
VIDEO_ENDED: "video:ended",
VIDEO_ELEMENT_FOUND: "video:element_found",
RANDOM_PLAY_TOGGLE: "random:toggle",
RANDOM_PLAY_NEXT: "random:next",
RANDOM_PLAY_PREV: "random:prev",
URL_CHANGED: "url:changed",
PLAYBACK_RATE_CHANGE: "playback:rate_change",
FULLSCREEN_TOGGLE: "fullscreen:toggle",
DOM_UPDATED: "dom:updated",
CHANNEL_MESSAGE: "channel:message",
KEY_PRESSED: "key:pressed",
MIDDLE_CLICK: "mouse:middle_click",
MEDIA_NEXT_TRACK: "media:next_track",
MEDIA_PREV_TRACK: "media:prev_track",
}
// 消息类型常量
const MESSAGES = {
PAUSE_OTHERS: "pauseOthers",
}
// 动作类型常量
const ACTIONS = {
INCREASE: "increase",
DECREASE: "decrease",
RESET: "reset",
}
// DOM属性常量
const DOM_PROPS = {
HAS_ENDED_LISTENER: "_hasEndedListener",
HAS_RANDOM_LISTENER: "_hasRandomListener",
}
// 通信频道名称
const CHANNEL_NAME = "bilibili_video_control"
//===========================================
// 工具函数模块
//===========================================
const DOMUtils = {
// 元素缓存
_elementCache: {},
/**
* 从选择器数组中查找第一个匹配的元素
* @param {string[]} selectors - 选择器数组
* @returns {HTMLElement|null} 找到的元素或null
*/
findFirstElement(selectors) {
const cacheKey = selectors.join("|")
if (this._elementCache[cacheKey]) {
const element = this._elementCache[cacheKey]
if (document.contains(element)) return element
}
for (const selector of selectors) {
const element = document.querySelector(selector)
if (element) {
this._elementCache[cacheKey] = element
return element
}
}
return null
},
/**
* 从选择器数组中查找所有匹配的元素
* @param {string[]} selectors - 选择器数组
* @returns {HTMLElement[]} 找到的元素数组
*/
findElements(selectors) {
const elements = []
for (const selector of selectors) {
const found = document.querySelector(selector)
if (found) elements.push(found)
}
return elements
},
/**
* 显示提示信息
* @param {string} message - 要显示的消息
*/
showNotification(message) {
// 检查是否已存在通知,如果存在则移除
const existingNotification = document.querySelector(".bilibili-enhancer-notification")
if (existingNotification) {
document.body.removeChild(existingNotification)
}
const notification = document.createElement("div")
notification.className = "bilibili-enhancer-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)
},
/**
* 模拟键盘按键
* @param {string} key - 要模拟的按键
*/
simulateKeyPress(key) {
if (!key || key.length === 0) return
// 创建一个键盘事件
const keyEvent = new KeyboardEvent(EVENTS.KEYDOWN, {
key: key,
code: `Key${key.toUpperCase()}`,
keyCode: key.charCodeAt(0),
which: key.charCodeAt(0),
bubbles: true,
cancelable: true,
})
// 分发事件到文档
document.dispatchEvent(keyEvent)
Logger.log(`模拟按键: ${key}`)
},
/**
* 清除元素缓存
*/
clearCache() {
this._elementCache = {}
},
}
/**
* 日志工具模块
*/
const Logger = {
PREFIX: "[B站增强]",
// 日志级别
LEVELS: {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
NONE: 4,
},
// 当前日志级别
level: 1, // 默认INFO级别
/**
* 设置日志级别
* @param {number} level - 日志级别
*/
setLevel(level) {
this.level = level
},
/**
* 输出调试日志
* @param {string} message - 日志消息
*/
debug(message) {
if (this.level <= this.LEVELS.DEBUG) {
console.debug(`${this.PREFIX} [DEBUG] ${message}`)
}
},
/**
* 输出普通日志
* @param {string} message - 日志消息
*/
log(message) {
if (this.level <= this.LEVELS.INFO) {
console.log(`${this.PREFIX} ${message}`)
}
},
/**
* 输出警告日志
* @param {string} message - 警告消息
*/
warn(message) {
if (this.level <= this.LEVELS.WARN) {
console.warn(`${this.PREFIX} ${message}`)
}
},
/**
* 输出错误日志
* @param {string} message - 错误消息
* @param {Error} [error] - 错误对象
*/
error(message, error) {
if (this.level <= this.LEVELS.ERROR) {
if (error) {
console.error(`${this.PREFIX} ${message}:`, error)
} else {
console.error(`${this.PREFIX} ${message}`)
}
}
},
}
/**
* 数学工具模块
*/
const MathUtils = {
/**
* 将值舍入到最接近的整数(如果差值小于0.1)
* @param {number} rate - 要舍入的值
* @returns {number} 舍入后的值
*/
roundToNearestInteger(rate) {
if (rate < 0.2) return 0.2
rate = Number.parseFloat(rate.toFixed(2))
const rounded = Math.round(rate)
return Math.abs(rate - rounded) < 0.1 ? rounded : rate
},
}
//===========================================
// 事件总线模块
//===========================================
const EventBus = {
// 事件监听器
listeners: {},
// 通信频道
channel: null,
// DOM观察器
observer: null,
/**
* 初始化事件总线
*/
init() {
// 创建通信频道
this.channel = new BroadcastChannel(CHANNEL_NAME)
// 设置频道消息监听
this.channel.addEventListener("message", (event) => {
this.emit(CUSTOM_EVENTS.CHANNEL_MESSAGE, event.data)
})
// 设置DOM观察器
this.observer = new MutationObserver(() => {
this.emit(CUSTOM_EVENTS.DOM_UPDATED)
})
// 开始观察DOM变化
this.observer.observe(document.body, { childList: true, subtree: true })
// 设置键盘事件监听
window.addEventListener(EVENTS.KEYDOWN, (event) => {
// 忽略输入框中的按键事件
if (["INPUT", "TEXTAREA"].includes(document.activeElement.tagName)) {
return
}
// 忽略带有修饰键的按键
if (event.ctrlKey || event.altKey || event.metaKey) {
return
}
const key = event.key.toLowerCase()
this.emit(CUSTOM_EVENTS.KEY_PRESSED, { key, event })
})
// 设置鼠标中键事件监听
document.addEventListener(EVENTS.MOUSEDOWN, (event) => {
// 检查是否是中键点击
if (event.button !== 1) return
// 检查点击的是否是视频元素
const video = VideoController.getVideo()
if (!video) return
// 检查点击位置是否在视频元素内
const rect = video.getBoundingClientRect()
if (
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
) {
this.emit(CUSTOM_EVENTS.MIDDLE_CLICK, { event, video })
event.preventDefault()
}
})
// 设置URL变化监听
const originalPushState = history.pushState
const originalReplaceState = history.replaceState
// 重写pushState
history.pushState = function () {
originalPushState.apply(this, arguments)
EventBus.emit(CUSTOM_EVENTS.URL_CHANGED, { url: window.location.href })
}
// 重写replaceState
history.replaceState = function () {
originalReplaceState.apply(this, arguments)
EventBus.emit(CUSTOM_EVENTS.URL_CHANGED, { url: window.location.href })
}
// 监听popstate事件(浏览器前进/后退)
window.addEventListener(EVENTS.POPSTATE, () => {
this.emit(CUSTOM_EVENTS.URL_CHANGED, { url: window.location.href })
})
// 监听视频元素变化
this.on(CUSTOM_EVENTS.DOM_UPDATED, () => {
const video = VideoController.getVideo()
if (video && !video[DOM_PROPS.HAS_ENDED_LISTENER]) {
video[DOM_PROPS.HAS_ENDED_LISTENER] = true
// 发送视频元素找到事件
this.emit(CUSTOM_EVENTS.VIDEO_ELEMENT_FOUND, { video })
// 监听视频播放事件
video.addEventListener(EVENTS.PLAY, () => {
this.emit(CUSTOM_EVENTS.VIDEO_PLAY, { video })
})
// 监听视频结束事件
video.addEventListener(EVENTS.ENDED, () => {
this.emit(CUSTOM_EVENTS.VIDEO_ENDED, { video })
})
}
})
// 设置媒体会话处理程序
this.setupMediaSessionHandlers()
Logger.log("事件总线已初始化")
},
/**
* 设置媒体会话处理程序
*/
setupMediaSessionHandlers() {
if ("mediaSession" in navigator) {
// 设置上一曲处理程序
navigator.mediaSession.setActionHandler("previoustrack", () => {
Logger.log("媒体会话: 上一曲(mediaSession)")
this.emit(CUSTOM_EVENTS.MEDIA_PREV_TRACK)
})
// 设置下一曲处理程序
navigator.mediaSession.setActionHandler("nexttrack", () => {
Logger.log("媒体会话: 下一曲(mediaSession)")
this.emit(CUSTOM_EVENTS.MEDIA_NEXT_TRACK)
})
Logger.log("媒体会话处理程序已注册(不可用)")
} else {
Logger.warn("媒体会话API不可用")
}
document.addEventListener("keyup", (e) => {
if (e.key === "MediaTrackPrevious") {
Logger.log("媒体会话: 上一曲(keyup)")
this.emit(CUSTOM_EVENTS.MEDIA_PREV_TRACK)
} else if (e.key === "MediaTrackNext") {
Logger.log("媒体会话: 下一曲(keyup)")
this.emit(CUSTOM_EVENTS.MEDIA_NEXT_TRACK)
}
}, true,)
},
/**
* 注册(不可用)事件监听器
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数
*/
on(event, callback) {
if (!this.listeners[event]) {
this.listeners[event] = []
}
this.listeners[event].push(callback)
return this // 支持链式调用
},
/**
* 移除事件监听器
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数
*/
off(event, callback) {
if (!this.listeners[event]) return this
if (callback) {
this.listeners[event] = this.listeners[event].filter((cb) => cb !== callback)
} else {
delete this.listeners[event]
}
return this // 支持链式调用
},
/**
* 触发事件
* @param {string} event - 事件名称
* @param {*} data - 事件数据
*/
emit(event, data = {}) {
Logger.debug(`事件触发: ${event}`)
if (!this.listeners[event]) return
this.listeners[event].forEach((callback) => {
try {
callback(data)
} catch (error) {
Logger.error(`事件处理器错误 (${event})`, error)
}
})
},
/**
* 发送频道消息
* @param {string} action - 动作类型
* @param {*} data - 消息数据
*/
sendMessage(action, data = {}) {
this.channel.postMessage({ action, ...data })
},
/**
* 清理资源
*/
destroy() {
this.listeners = {}
if (this.channel) {
this.channel.close()
}
if (this.observer) {
this.observer.disconnect()
}
},
}
//===========================================
// 视频控制模块
//===========================================
const VideoController = {
/**
* 获取视频元素
* @returns {HTMLElement|null} 视频元素
*/
getVideo() {
return DOMUtils.findFirstElement(SELECTORS.VIDEO)
},
/**
* 尝试自动播放视频
* @returns {Promise<boolean>} 是否成功播放
*/
tryAutoPlay() {
if (!CONFIG.autoPlay) return Promise.resolve(false)
const video = this.getVideo()
if (!video) return Promise.resolve(false)
return video
.play()
.then(() => {
Logger.log("自动播放成功")
return true
})
.catch((error) => {
Logger.log("自动播放被拦截,不再尝试")
Logger.debug(`自动播放错误: ${error.message}`)
return false
})
},
/**
* 暂停视频
* @returns {boolean} 是否成功暂停
*/
pause() {
const video = this.getVideo()
if (!video || video.paused) return false
video.pause()
return true
},
/**
* 调整视频播放速度
* @param {string|number} action - "increase", "decrease", "reset", or a specific rate
*/
adjustPlaybackSpeed(action) {
const video = this.getVideo()
if (!video) return
const currentRate = video.playbackRate
if (typeof action === "number") {
// 如果已经是该速度,则恢复为1.0倍速
if (Math.abs(video.playbackRate - action) < 0.01) {
video.playbackRate = 1.0
DOMUtils.showNotification(`播放速度: 1.0x`)
} else {
video.playbackRate = action
DOMUtils.showNotification(`播放速度: ${action.toFixed(1)}x`)
}
return
}
switch (action) {
case ACTIONS.RESET:
video.playbackRate = 1.0
DOMUtils.showNotification(`播放速度: 1.0x`)
break
case ACTIONS.DECREASE:
video.playbackRate = MathUtils.roundToNearestInteger(currentRate * 0.8)
DOMUtils.showNotification(`播放速度: ${video.playbackRate.toFixed(1)}x`)
break
case ACTIONS.INCREASE:
video.playbackRate = MathUtils.roundToNearestInteger(currentRate * 1.2)
DOMUtils.showNotification(`播放速度: ${video.playbackRate.toFixed(1)}x`)
break
}
// 触发播放速度变化事件
EventBus.emit(CUSTOM_EVENTS.PLAYBACK_RATE_CHANGE, {
rate: video.playbackRate,
previousRate: currentRate,
})
},
/**
* 切换网页全屏状态
*/
toggleWebFullscreen() {
const fullscreenButton = document.querySelector(SELECTORS.WEB_FULLSCREEN_BUTTON)
if (fullscreenButton) {
fullscreenButton.click()
Logger.log("切换网页全屏")
// 触发全屏切换事件
EventBus.emit(CUSTOM_EVENTS.FULLSCREEN_TOGGLE)
}
},
/**
* 更新播放速度菜单列表
* @returns {boolean} 是否更新成功
*/
updatePlaybackRateMenu() {
const menu = document.querySelector(SELECTORS.PLAYBACK_RATE_MENU)
if (!menu) return false
const currentRates = Array.from(menu.children).map((el) => el.getAttribute("data-value"))
const newRates = [...CONFIG.playbackRates].reverse()
// 避免不必要的更新
if (JSON.stringify(currentRates) === JSON.stringify(newRates)) return false
menu.innerHTML = ""
newRates.forEach((rate) => {
const item = document.createElement("li")
item.className = "bpx-player-ctrl-playbackrate-menu-item"
item.setAttribute("data-value", rate)
item.textContent = rate + "x"
menu.appendChild(item)
})
Logger.log("播放速度菜单已更新")
return true
},
}
//===========================================
// 随机播放模块
//===========================================
const RandomPlayModule = {
// 随机播放相关状态
state: {
shuffleButtonAdded: false,
buttonCheckInterval: null,
isRandomPlayEnabled: false,
urlParamProcessed: false,
originalPlaylist: [],
shuffledPlaylist: [],
currentPlayIndex: -1,
},
/**
* 初始化随机播放功能
*/
init() {
// 使用间隔检查按钮是否存在
this.state.buttonCheckInterval = setInterval(
() => this.checkAndAddRandomButton(),
CONFIG.timing.buttonCheckInterval,
)
// 初始尝试添加按钮
setTimeout(() => this.checkAndAddRandomButton(), CONFIG.timing.initialDelay)
// 监听URL变化
EventBus.on(CUSTOM_EVENTS.URL_CHANGED, () => this.checkURLParams())
// 检查URL参数
setTimeout(() => this.checkURLParams(), CONFIG.timing.urlParamCheckDelay)
// 监听视频结束事件
EventBus.on(CUSTOM_EVENTS.VIDEO_ENDED, () => {
Logger.log("视频播放结束,检查随机播放状态")
if (this.state.isRandomPlayEnabled) {
setTimeout(() => this.playNextInQueue(), CONFIG.timing.playRandomDelay)
}
})
// 监听随机播放事件
EventBus.on(CUSTOM_EVENTS.RANDOM_PLAY_TOGGLE, () => {
const shuffleBtn = document.querySelector(".shuffle-btn")
if (shuffleBtn) shuffleBtn.click()
})
EventBus.on(CUSTOM_EVENTS.RANDOM_PLAY_NEXT, () => {
this.playNextVideo()
})
EventBus.on(CUSTOM_EVENTS.RANDOM_PLAY_PREV, () => {
this.playPrevVideo()
})
// 监听媒体会话事件
EventBus.on(CUSTOM_EVENTS.MEDIA_NEXT_TRACK, () => {
Logger.log("媒体会话: 下一曲 -> 随机播放下一个")
this.playNextVideo()
})
EventBus.on(CUSTOM_EVENTS.MEDIA_PREV_TRACK, () => {
Logger.log("媒体会话: 上一曲 -> 随机播放上一个")
this.playPrevVideo()
})
},
/**
* 检查并添加随机播放按钮
*/
checkAndAddRandomButton() {
// 检查是否已经添加了随机按钮,避免重复添加
if (document.querySelector(".shuffle-btn")) {
return
}
// 查找自动连播按钮容器
const autoPlayContainer = document.querySelector(SELECTORS.AUTO_PLAY)
if (autoPlayContainer) {
this.createShuffleButton(autoPlayContainer)
this.state.shuffleButtonAdded = true
Logger.log("随机播放按钮已添加")
// 成功添加按钮后清除检查间隔
if (this.state.buttonCheckInterval) {
clearInterval(this.state.buttonCheckInterval)
this.state.buttonCheckInterval = null
}
// 设置上一个/下一个按钮监听
this.setupPrevNextButtonsListener()
// 初始化播放列表
this.initializePlaylist()
// 再次检查URL参数(确保按钮已添加后再处理)
if (!this.state.urlParamProcessed) {
this.checkURLParams()
}
}
},
/**
* 创建随机播放按钮
* @param {HTMLElement} autoPlayContainer - 自动连播按钮容器
*/
createShuffleButton(autoPlayContainer) {
// 克隆自动连播按钮作为模板
const shuffleContainer = autoPlayContainer.cloneNode(true)
shuffleContainer.className = "shuffle-btn auto-play" // 保持相同的样式类
// 添加右侧间距
shuffleContainer.style.margin = CONFIG.styles.buttonMargin
// 修改文本和图标
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", "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(EVENTS.CLICK, () => this.toggleRandomPlay())
}
},
/**
* 初始化播放列表
*/
initializePlaylist() {
// 获取所有播放列表项
const playlistItems = Array.from(document.querySelectorAll(SELECTORS.PLAYLIST_ITEM))
if (playlistItems.length <= 1) return
// 保存原始播放列表
this.state.originalPlaylist = playlistItems
// 初始化打乱的播放列表(初始为空,会在开启随机播放时生成)
this.state.shuffledPlaylist = []
// 找到当前播放的视频索引
const activeItem = document.querySelector(SELECTORS.ACTIVE_PLAYLIST_ITEM)
this.state.currentPlayIndex = activeItem ? playlistItems.indexOf(activeItem) : 0
Logger.log(`播放列表已初始化,共 ${playlistItems.length} 个视频,当前索引: ${this.state.currentPlayIndex}`)
},
/**
* 生成随机播放队列
*/
generateShuffledPlaylist() {
// 确保原始播放列表已初始化
if (this.state.originalPlaylist.length === 0) {
this.initializePlaylist()
if (this.state.originalPlaylist.length === 0) return
}
// 创建索引数组
const indices = Array.from({ length: this.state.originalPlaylist.length }, (_, i) => i)
// 获取当前播放的视频索引
const activeItem = document.querySelector(SELECTORS.ACTIVE_PLAYLIST_ITEM)
const currentIndex = activeItem ? this.state.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)
}
// 保存打乱后的播放列表
this.state.shuffledPlaylist = indices.map((index) => this.state.originalPlaylist[index])
this.state.currentPlayIndex = 0
Logger.log("随机播放队列已生成:", indices)
},
/**
* 切换随机播放状态
* @param {boolean} [forceState] - 可选,强制设置为指定状态
*/
toggleRandomPlay(forceState) {
// 如果提供了强制状态,则使用它,否则切换当前状态
if (typeof forceState === "boolean") {
this.state.isRandomPlayEnabled = forceState
} else {
this.state.isRandomPlayEnabled = !this.state.isRandomPlayEnabled
}
// 更新按钮状态
const shuffleBtn = document.querySelector(".shuffle-btn")
if (shuffleBtn) {
// 更新开关按钮的状态
const switchElement = shuffleBtn.querySelector(".switch-btn")
if (switchElement) {
if (this.state.isRandomPlayEnabled) {
switchElement.className = "switch-btn on"
// 生成随机播放队列
this.generateShuffledPlaylist()
} else {
switchElement.className = "switch-btn off"
// 清空随机播放队列
this.state.shuffledPlaylist = []
}
} else {
// 如果没有开关元素,使用颜色来表示状态
if (this.state.isRandomPlayEnabled) {
shuffleBtn.style.color = CONFIG.styles.bilibiliBlue
// 生成随机播放队列
this.generateShuffledPlaylist()
} else {
shuffleBtn.style.color = ""
// 清空随机播放队列
this.state.shuffledPlaylist = []
}
}
}
// 更新URL参数
this.updateURLParam()
Logger.log(this.state.isRandomPlayEnabled ? "已开启随机播放模式" : "已关闭随机播放模式")
},
/**
* 设置上一个/下一个按钮监听
*/
setupPrevNextButtonsListener() {
// 监听按钮变化
EventBus.on(CUSTOM_EVENTS.DOM_UPDATED, () => {
// 遍历所有可能的按钮选择器
SELECTORS.PREV_NEXT_BUTTONS.forEach((selector) => {
const button = document.querySelector(selector)
if (button && !button[DOM_PROPS.HAS_RANDOM_LISTENER]) {
button[DOM_PROPS.HAS_RANDOM_LISTENER] = true
// 保存原始的点击处理函数
const originalClickHandler = button.onclick
// 替换为我们的处理函数
button.onclick = (e) => {
if (this.state.isRandomPlayEnabled) {
e.preventDefault()
e.stopPropagation()
// 根据按钮类型决定播放上一个还是下一个
if (selector.includes("prev")) {
this.playPrevInQueue()
} else {
this.playNextInQueue()
}
return false
} else if (originalClickHandler) {
return originalClickHandler.call(button, e)
}
}
// 如果是div元素,还需要监听click事件
if (button.tagName.toLowerCase() === "div") {
button.addEventListener(EVENTS.CLICK, (e) => {
if (this.state.isRandomPlayEnabled) {
e.preventDefault()
e.stopPropagation()
// 根据按钮类型决定播放上一个还是下一个
if (selector.includes("prev")) {
this.playPrevInQueue()
} else {
this.playNextInQueue()
}
return false
}
})
}
Logger.log(`按钮监听器已添加: ${selector}`)
}
})
})
},
/**
* 播放队列中的下一个视频
*/
playNextInQueue() {
if (!this.state.isRandomPlayEnabled || this.state.shuffledPlaylist.length === 0) {
// 如果随机播放未启用或队列为空,则随机选择一个视频播放
this.playRandomVideo()
return
}
// 移动到下一个索引
this.state.currentPlayIndex = (this.state.currentPlayIndex + 1) % this.state.shuffledPlaylist.length
// 播放当前索引的视频
const videoToPlay = this.state.shuffledPlaylist[this.state.currentPlayIndex]
if (videoToPlay) {
this.playVideo(videoToPlay)
} else {
// 如果出现问题,重新生成队列并播放
this.generateShuffledPlaylist()
this.playNextInQueue()
}
},
/**
* 播放队列中的上一个视频
*/
playPrevInQueue() {
if (!this.state.isRandomPlayEnabled || this.state.shuffledPlaylist.length === 0) {
// 如果随机播放未启用或队列为空,则随机选择一个视频播放
this.playRandomVideo()
return
}
// 移动到上一个索引
this.state.currentPlayIndex =
(this.state.currentPlayIndex - 1 + this.state.shuffledPlaylist.length) % this.state.shuffledPlaylist.length
// 播放当前索引的视频
const videoToPlay = this.state.shuffledPlaylist[this.state.currentPlayIndex]
if (videoToPlay) {
this.playVideo(videoToPlay)
} else {
// 如果出现问题,重新生成队列并播放
this.generateShuffledPlaylist()
this.playPrevInQueue()
}
},
/**
* 随机播放一个视频(不使用队列,完全随机)
*/
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)
Logger.log(`播放随机视频: ${randomIndex + 1}/${playlistItems.length}`)
// 播放随机选择的视频
this.playVideo(playlistItems[randomIndex])
},
/**
* 播放指定的视频
* @param {HTMLElement} videoElement - 要播放的视频元素
*/
playVideo(videoElement) {
if (!videoElement) return
// 尝试获取链接并导航
const link = videoElement.querySelector("a")
if (link && link.href) {
Logger.log(`导航至: ${link.href}`)
// 添加随机播放参数到URL
const url = new URL(link.href)
if (this.state.isRandomPlayEnabled) {
url.searchParams.set(CONFIG.urlParams.random, CONFIG.urlParams.enabledValue)
}
window.location.href = url.toString()
} else {
// 如果无法获取链接,尝试模拟点击
Logger.log("模拟点击播放列表项")
videoElement.click()
}
},
/**
* 播放上一个视频(如果随机模式开启则使用队列)
*/
playPrevVideo() {
if (this.state.isRandomPlayEnabled) {
this.playPrevInQueue()
} else {
// 尝试点击上一个按钮
const prevButton = DOMUtils.findFirstElement(SELECTORS.PREV_NEXT_BUTTONS.filter((s) => s.includes("prev")))
if (prevButton) {
prevButton.click()
}
}
},
/**
* 播放下一个视频(如果随机模式开启则使用队列)
*/
playNextVideo() {
if (this.state.isRandomPlayEnabled) {
this.playNextInQueue()
} else {
// 尝试点击下一个按钮
const nextButton = DOMUtils.findFirstElement(SELECTORS.PREV_NEXT_BUTTONS.filter((s) => s.includes("next")))
if (nextButton) {
nextButton.click()
}
}
},
/**
* 检查URL参数
*/
checkURLParams() {
const url = new URL(window.location.href)
const randomParam = url.searchParams.get(CONFIG.urlParams.random)
// 标记URL参数已处理
this.state.urlParamProcessed = true
// 如果存在随机播放参数并且值为启用值
if (randomParam === CONFIG.urlParams.enabledValue) {
Logger.log("在URL中检测到随机播放参数")
// 如果按钮已添加,则启用随机播放
if (document.querySelector(".shuffle-btn")) {
if (!this.state.isRandomPlayEnabled) {
Logger.log("从URL参数启用随机播放")
this.toggleRandomPlay(true)
// 如果有播放列表,则播放随机视频
setTimeout(() => {
if (document.querySelectorAll(SELECTORS.PLAYLIST_ITEM).length > 1) {
this.playRandomVideo()
}
}, CONFIG.timing.playRandomDelay)
}
} else {
// 如果按钮尚未添加,则设置标志以便稍后处理
Logger.log("按钮尚未添加,将在按钮准备好时启用随机播放")
this.state.urlParamProcessed = false
}
}
},
/**
* 更新URL参数
*/
updateURLParam() {
// 获取当前URL
const url = new URL(window.location.href)
// 根据随机播放状态设置或移除参数
if (this.state.isRandomPlayEnabled) {
url.searchParams.set(CONFIG.urlParams.random, CONFIG.urlParams.enabledValue)
} else {
url.searchParams.delete(CONFIG.urlParams.random)
}
// 使用replaceState更新URL,不触发页面刷新
try {
window.history.replaceState({}, document.title, url.toString())
Logger.log(`URL已更新: ${url.toString()}`)
} catch (e) {
Logger.error("更新URL失败", e)
}
},
}
//===========================================
// 播放增强模块
//===========================================
const PlaybackEnhancementModule = {
// 播放器状态
state: {
lastUpdate: 0,
isInitialized: false,
},
/**
* 初始化播放增强功能
*/
init() {
// 监听DOM变化以适应B站的SPA特性
EventBus.on(CUSTOM_EVENTS.DOM_UPDATED, () => this.updatePlayer())
// 初始更新播放器
this.updatePlayer()
},
/**
* 更新播放器功能(可能需要定期执行)
*/
updatePlayer() {
// 防止频繁更新
const now = Date.now()
if (now - this.state.lastUpdate < CONFIG.timing.updateInterval) return
// 如果配置了播放速度选项,则更新播放速度菜单
if (CONFIG.playbackRates && CONFIG.playbackRates.length > 0) {
VideoController.updatePlaybackRateMenu()
}
this.state.lastUpdate = now
},
}
//===========================================
// 键盘和鼠标控制模块
//===========================================
const KeyboardMouseModule = {
/**
* 初始化键盘和鼠标控制
*/
init() {
// 监听键盘事件
EventBus.on(CUSTOM_EVENTS.KEY_PRESSED, ({ key }) => {
this.triggerKeyFunction(key)
})
// 监听鼠标中键事件
EventBus.on(CUSTOM_EVENTS.MIDDLE_CLICK, () => {
if (CONFIG.keys.middleClick) {
// 先尝试触发预定义的按键功能
const handled = this.triggerKeyFunction(CONFIG.keys.middleClick)
// 如果没有预定义功能处理,则模拟键盘按键
if (!handled) {
DOMUtils.simulateKeyPress(CONFIG.keys.middleClick)
}
}
})
Logger.log("键盘和鼠标控制已初始化")
},
/**
* 触发按键对应的功能
* @param {string} key - 按键
* @returns {boolean} 是否处理了该按键
*/
triggerKeyFunction(key) {
if (!key) return false
// 根据按键执行相应功能
switch (key) {
case CONFIG.keys.toggleRandom:
EventBus.emit(CUSTOM_EVENTS.RANDOM_PLAY_TOGGLE)
return true
case CONFIG.keys.playRandom:
EventBus.emit(CUSTOM_EVENTS.RANDOM_PLAY_NEXT)
return true
case CONFIG.keys.prevVideo:
EventBus.emit(CUSTOM_EVENTS.RANDOM_PLAY_PREV)
return true
case CONFIG.keys.nextVideo:
EventBus.emit(CUSTOM_EVENTS.RANDOM_PLAY_NEXT)
return true
case CONFIG.keys.decreaseSpeed:
VideoController.adjustPlaybackSpeed(ACTIONS.DECREASE)
return true
case CONFIG.keys.increaseSpeed:
VideoController.adjustPlaybackSpeed(ACTIONS.INCREASE)
return true
case CONFIG.keys.resetSpeed:
VideoController.adjustPlaybackSpeed(ACTIONS.RESET)
return true
case CONFIG.keys.webFullscreen:
VideoController.toggleWebFullscreen()
return true
default:
// 检查是否是数字键
if (key >= "0" && key <= "9") {
const keyNum = Number.parseInt(key)
const index = keyNum === 0 ? 9 : keyNum - 1
if (CONFIG.playbackRates && index < CONFIG.playbackRates.length) {
VideoController.adjustPlaybackSpeed(Number.parseFloat(CONFIG.playbackRates[index]))
return true
}
}
return false
}
},
}
//===========================================
// 主应用模块
//===========================================
const App = {
/**
* 主初始化函数
*/
init() {
Logger.log("初始化中...")
// 初始化事件总线
EventBus.init()
// 检查URL参数,判断是否有随机播放参数
const url = new URL(window.location.href)
const hasRandomParam = url.searchParams.get(CONFIG.urlParams.random) === CONFIG.urlParams.enabledValue
// 自动播放功能
if (CONFIG.autoPlay && !hasRandomParam) {
// 修复:当有随机播放参数时,不执行自动播放
VideoController.tryAutoPlay()
}
// 自动暂停功能
if (CONFIG.autoPause) {
// 监听视频播放事件,通知其他标签页暂停
EventBus.on(CUSTOM_EVENTS.VIDEO_PLAY, () => {
EventBus.sendMessage(MESSAGES.PAUSE_OTHERS)
})
// 监听频道消息,暂停当前标签页的视频
EventBus.on(CUSTOM_EVENTS.CHANNEL_MESSAGE, (data) => {
if (data.action === MESSAGES.PAUSE_OTHERS) {
if (VideoController.pause()) {
Logger.log("其他页面播放,本页面已暂停")
}
}
})
}
// 随机播放功能
if (CONFIG.randomPlayButton) {
RandomPlayModule.init()
}
// 自定义倍速功能
if (CONFIG.playbackRates && CONFIG.playbackRates.length > 0) {
PlaybackEnhancementModule.init()
}
// 键盘和鼠标控制
KeyboardMouseModule.init()
Logger.log("初始化完成")
},
/**
* 获取配置
* @returns {Object} 当前配置
*/
getConfig() {
return { ...CONFIG }
},
/**
* 更新配置
* @param {Object} newConfig - 新配置
*/
updateConfig(newConfig) {
Object.assign(CONFIG, newConfig)
Logger.log("配置已更新")
return { ...CONFIG }
},
}
//===========================================
// 公共API
//===========================================
// 创建全局API对象
window.BilibiliEnhancer = {
// 配置相关
getConfig: App.getConfig,
updateConfig: App.updateConfig,
// 视频控制
getVideo: VideoController.getVideo,
adjustPlaybackSpeed: (rate) => VideoController.adjustPlaybackSpeed(rate),
toggleWebFullscreen: () => VideoController.toggleWebFullscreen(),
// 随机播放
toggleRandomPlay: (state) => RandomPlayModule.toggleRandomPlay(state),
playNextVideo: () => RandomPlayModule.playNextVideo(),
playPrevVideo: () => RandomPlayModule.playPrevVideo(),
playRandomVideo: () => RandomPlayModule.playRandomVideo(),
// 事件相关
on: (event, callback) => EventBus.on(event, callback),
off: (event, callback) => EventBus.off(event, callback),
emit: (event, data) => EventBus.emit(event, data),
// 工具函数
showNotification: (message) => DOMUtils.showNotification(message),
// 日志相关
setLogLevel: (level) => Logger.setLevel(level),
// 常量
EVENTS: CUSTOM_EVENTS,
ACTIONS: ACTIONS,
// 版本信息
VERSION: "1.0",
}
//===========================================
// 初始化
//===========================================
// 页面加载完成后初始化
if (document.readyState === "complete") {
App.init()
} else {
window.addEventListener(EVENTS.LOAD, () => { setTimeout(App.init, 0) })
}
})()