// ==UserScript==
// @name BiliBili 高级倍速功能
// @namespace cec8225d12878f0fc33997eb79a69894
// @version 1.8
// @description BiliBili倍速插件,支持自定义速度、记忆上一次速度、快捷键调速。
// @author TheBszk
// @match https://www.bilibili.com/video/*
// @match https://www.bilibili.com/list/*
// @match https://www.bilibili.com/bangumi/play/*
// @match https://www.bilibili.com/cheese/play/*
// @match https://www.bilibili.com/festival/*
// @icon https://www.bilibili.com/favicon.ico
// @license AGPL
// ==/UserScript==
(function () {
"use strict";
const CUSTOM_RATE_ARRAY = "custom_rate_array";
const CUSTOM_RATE = "custom_rate";
const CUSTOM_ShowTimeState = "custom_showtimestate";
const CUSTOM_ArrowRightSpeed = "custom_arrowrightspeed";
const CUSTOM_SwitchCustomSpeed = "custom_switchcustomspeed";
const CUSTOM_DefaultWideScreen = "custom_defaultwidescreen";
const CUSTOM_Volume = "custom_volume";
const CUSTOM_GlobalVolumeAdjustment = "custom_globalvolumeadjustment";
if (!localStorage.getItem(CUSTOM_ArrowRightSpeed)) {
localStorage.setItem(CUSTOM_ArrowRightSpeed, "2x"); //设置默认值
}
function getPageType() {
const path = window.location.pathname;
if (path.startsWith("/video/")) {
return "video";
} else if (path.startsWith("/list/")) {
return "list";
} else if (path.startsWith("/bangumi/play/")) {
return "bangumi";
} else if (path.startsWith("/cheese/play/")) {
return "cheese";
} else if (path.startsWith("/festival/")) {
return "festival";
} else {
return "unknown";
}
}
const pageType = getPageType();
if (pageType == "video" || pageType == "list" || pageType == "bangumi" || pageType == "cheese" || pageType == "festival") {
var MENUCLASS = "bpx-player-ctrl-playbackrate-menu";
var MENUCLASS_ITEM = "bpx-player-ctrl-playbackrate-menu-item";
var MENUCLASS_ACTIVE = "bpx-state-active";
} else {
return;
}
function getRate() {
let rate = localStorage.getItem(CUSTOM_RATE);
if (rate <= 0) {
rate = 1;
}
return rate;
}
function getRateArray() {
let storageData = localStorage.getItem(CUSTOM_RATE_ARRAY);
let rates;
if (storageData == null) {
rates = [];
} else {
rates = storageData.split(",");
}
if (rates.length === 0) {
//如果没有,则初始化一个默认的
rates = [0.5, 1.0, 1.5, 2, 2.5, 3.0, 4.0];
localStorage.setItem(CUSTOM_RATE_ARRAY, rates.join(","));
}
return rates;
}
// 创建显示元素
function createTip() {
var elem = document.createElement("div");
elem.style.display = "none";
elem.style.position = "absolute";
elem.style.backgroundColor = "rgba(255, 255, 255, 0.2)";
elem.style.color = "white";
elem.style.padding = "5px";
elem.style.borderRadius = "5px";
elem.style.zIndex = "1000";
elem.style.fontSize = "22px";
return elem;
}
var timeDisplay = createTip();
timeDisplay.style.top = "20px";
timeDisplay.style.right = "20px";
let _showtime;
function setShowTimeState(state) {
localStorage.setItem(CUSTOM_ShowTimeState, state);
if (state == true) {
timeDisplay.style.display = "block";
if (!_showtime) {
_showtime = setInterval(FlashShowTime, 1000);
}
} else {
timeDisplay.style.display = "none";
if (_showtime) {
clearInterval(_showtime);
_showtime = 0;
}
}
}
var tipDisplay = createTip();
tipDisplay.style.right = "20px";
let hideTimer;
let bpx_player_control_entity = document.querySelector(".bpx-player-control-entity");
let bpx_player_control_wrap = document.querySelector(".bpx-player-control-wrap");
function showTip(title, time) {
let h;
if (bpx_player_control_entity.getAttribute("data-shadow-show") == "true") {
h = 0;
} else {
h = bpx_player_control_wrap.clientHeight;
}
tipDisplay.style.bottom = h + 20 + "px";
tipDisplay.textContent = title;
tipDisplay.style.display = "block";
if (!hideTimer) {
clearTimeout(hideTimer);
}
hideTimer = setTimeout(function () {
tipDisplay.style.display = "none";
}, time);
}
function showPlayRate(rate) {
showTip(`速度: ${rate}x`, 1200);
}
function showVolume(volume) {
showTip(`音量: ${volume}%`, 1200);
}
class SettingPopup {
popup_dragend_move(e) {
this.popup.style.left = e.clientX - this.offsetX + this.startX + "px";
this.popup.style.top = e.clientY - this.offsetY + this.startY + "px";
}
constructor() {
this.speedlist = getRateArray().join(",");
this.ArrowRightTime = localStorage.getItem(CUSTOM_ArrowRightSpeed);
this.SwitchCustomSpeed = localStorage.getItem(CUSTOM_SwitchCustomSpeed) == "true" ? true : false;
// this.GlobalVolumeAdjustment = localStorage.getItem(CUSTOM_GlobalVolumeAdjustment) == "true" ? true : false;
this.GlobalVolumeAdjustment = false;//不再开启
this.DefaultWideScreen = localStorage.getItem(CUSTOM_DefaultWideScreen) == "true" ? true : false;
let v = localStorage.getItem(CUSTOM_Volume);
if (v == null || v == "") {
this.volume = -1;
} else {
this.volume = parseInt(v);
}
}
create(handle) {
this.popup = document.createElement("div");
this.popup.innerHTML = `
<div class="popup-title" id="popupTitle">
<span>BiliBili 高级倍速功能</span>
<button class="close-button">×</button>
</div>
<div class="popup-content">
<label for="SpeedList">自定义倍速列表:</label>
<input type="text" id="SpeedList" placeholder="以英文逗号隔开" />
<label for="ArrowRightSpeed">长按右光标键速度:</label>
<input type="text" id="ArrowRightSpeed" placeholder="例: 2 为固定二倍速, 2x 为当前速度两倍" />
<label title="默认为对应速度(如 按2为2倍速、按3为3倍速)"><input type="checkbox" id="SwitchCustomSpeed" /> 0~9/Ctrl+0~9 快捷键切换自定义列表速度</label>
<br />
<label title="默认宽屏"><input type="checkbox" id="DefaultWideScreen" /> 默认宽屏</label>
<br />
<label title="[!]此功能放弃维护,因此不再可以使用\n\n↑ / ↓ 全局调整音量 ; 优化滚轮调整音量体验\n支持 0 ~ 500%(过高会有轻微失真)\n仅支持快捷键与鼠标滚轮调整\n\n[!] 本项修改需要刷新网页后生效"><input type="checkbox" id="GlobalVolumeAdjustment" disabled/> 接管音量控制</label>
<br />
<label title="暂不支持取消"><input type="checkbox" disabled checked /> 增加快捷键: 字幕切换(Z)</label>
<br />
<label title="暂不支持取消"><input type="checkbox" disabled checked /> 增加快捷键: 网页全屏(G)</label>
<br />
<label title="暂不支持取消"><input type="checkbox" disabled checked /> 增加快捷键: 宽屏模式(H)</label>
<br />
<label title="暂不支持取消"><input type="checkbox" disabled checked /> 双击字幕复制内容</label>
</div>
<div id="popup-tips">关闭设置窗口自动保存 | 鼠标停留查看更多信息</div>
`;
this.popup.classList.add("popup-container");
this.popupcss = document.createElement("style");
this.popupcss.innerHTML = `
.popup-container {
width: 330px;
position: absolute;
z-index: 999999;
background-color: #fff;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 0 10px rgba(33,150,243,0.5);
}
.popup-container .popup-title {
position: relative;
background-color: #3498db;
color: #fff;
padding: 10px;
cursor: move;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
user-select: none;
}
.popup-container .popup-content {
padding: 20px;
}
.popup-container .close-button {
position: absolute;
top: 0px;
right: 0px;
height: 100%;
background-color: #3498db;
color: #fff;
border: none;
padding: 0px 13px;
font-size: 24px;
cursor: pointer;
border-top-right-radius: 8px;
transition: background-color 0.3s ease, transform 0.3s ease;
}
.popup-container .close-button:hover {
background-color: #e74c3c;
}
.popup-container label {
font-size: 14px;
}
.popup-container #popup-tips {
color: #555555;
font-size: 14px;
padding: 4px 0px 4px 10px;
border-top: 1px solid #ccc;
}
.popup-container .button {
display: block;
padding: 10px;
background-color: #3498db;
color: #fff;
text-align: center;
text-decoration: none;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s ease;
border: none;
}
.popup-container .button:hover {
background-color: #2980b9;
}
.popup-container select,
input[type="text"] {
display: block;
margin-bottom: 10px;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
width: 100%;
box-sizing: border-box;
outline: none;
}
.popup-container input[type="text"]:focus {
border: 1px solid #2980b9;
}
.popup-container input[type="radio"] {
margin-right: 5px;
}`;
document.body.appendChild(this.popup);
document.head.appendChild(this.popupcss);
this.popup_dragend_move = this.popup_dragend_move.bind(this);
this.popup.querySelector("#popupTitle").addEventListener("mousedown", (e) => {
this.offsetX = e.clientX;
this.offsetY = e.clientY;
this.startX = parseInt(this.popup.style.left);
this.startY = parseInt(this.popup.style.top);
document.addEventListener("mousemove", this.popup_dragend_move);
document.addEventListener("mouseup", (e) => {
document.removeEventListener("mousemove", this.popup_dragend_move);
});
});
this.popup.querySelector(".close-button").addEventListener("click", (e) => {
this.close();
});
this.handle = handle;
}
show() {
this.popup.querySelector("#SpeedList").value = this.speedlist;
this.popup.querySelector("#ArrowRightSpeed").value = this.ArrowRightTime;
this.popup.querySelector("#SwitchCustomSpeed").checked = this.SwitchCustomSpeed;
this.popup.querySelector("#GlobalVolumeAdjustment").checked = this.GlobalVolumeAdjustment;
this.popup.querySelector("#DefaultWideScreen").checked = this.DefaultWideScreen;
this.popup.style.display = "block";
let left = (window.innerWidth - this.popup.offsetWidth) / 2;
let top = (window.innerHeight - this.popup.offsetHeight) / 2;
this.popup.style.left = left + "px";
this.popup.style.top = top + "px";
}
close() {
let sl, ars;
// 读取元素的值
sl = this.popup.querySelector("#SpeedList").value;
ars = this.popup.querySelector("#ArrowRightSpeed").value;
this.SwitchCustomSpeed = this.popup.querySelector("#SwitchCustomSpeed").checked;
this.GlobalVolumeAdjustment = this.popup.querySelector("#GlobalVolumeAdjustment").checked;
this.DefaultWideScreen = this.popup.querySelector("#DefaultWideScreen").checked;
let sl_ = null,
ars_ = null;
//进行处理
//自定义速度列表
if (!(sl === null || sl.trim() === "")) {
let rates = sl
.split(",")
.map((s) => s.trim())
.filter((s) => s);
if (rates.length > 0) {
// 检查输入是否全部为有效数字
if (rates.every((s) => isFinite(s))) {
localStorage.setItem(CUSTOM_RATE_ARRAY, rates.join(","));
this.speedlist = sl;
sl_ = rates;
}
}
}
//右光标键速度
if (parseInt(ars) > 0) {
localStorage.setItem(CUSTOM_ArrowRightSpeed, ars);
this.ArrowRightTime = ars;
ars_ = ars;
}
localStorage.setItem(CUSTOM_SwitchCustomSpeed, this.SwitchCustomSpeed);
localStorage.setItem(CUSTOM_GlobalVolumeAdjustment, this.GlobalVolumeAdjustment);
localStorage.setItem(CUSTOM_DefaultWideScreen, this.DefaultWideScreen);
this.handle(sl_, ars_);
this.popup.remove();
}
}
let setting = new SettingPopup();
class PlayRateMenu {
init(menu) {
this.videoObj = document.querySelector("video");
if (this.videoObj) {
if (setting.GlobalVolumeAdjustment) {
let context = new (window.AudioContext || window.webkitAudioContext)();
this.gain = context.createGain();
context.createMediaElementSource(this.videoObj).connect(this.gain);
this.gain.connect(context.destination);
this.volumeNumElem = document.querySelector(".bpx-player-ctrl-volume-number");
if (setting.volume != -1) {
this.setVolume(setting.volume / 100, false);
}
this.videoObj.addEventListener("volumechange", () => {
if (this.gain) {
this.gain.gain.value = 1;
}
});
}
} else {
this.videoObj = document.querySelector("bwp-video"); //b站自研wasm软解视频播放器
}
if (!this.videoObj) {
return false;
}
this.saveSetting = this.saveSetting.bind(this);
this.menu = menu;
this.rates = getRateArray();
this.videoObj.addEventListener("loadedmetadata", () => {
this.setRate(getRate());
});
if (setting.DefaultWideScreen) {
document.querySelector("#bilibili-player .bpx-player-ctrl-wide span").click();
}
return true;
}
insertRate(rateValue) {
this.rates.push(rateValue);
this.render();
}
insertItem(content, rate, event) {
const item = document.createElement("li");
item.textContent = content;
item.classList.add(MENUCLASS_ITEM);
item.setAttribute("data-value", rate);
item.addEventListener("click", event);
this.menu.appendChild(item);
}
saveSetting(sl, ars) {
if (sl != null) {
this.rates = sl;
this.render();
let nowRate = getRate();
if (this.rates.indexOf(nowRate) === -1) {
this.setRate(1);
} else {
this.setRate(nowRate);
}
}
}
render() {
this.menu.innerHTML = "";
this.rates.sort((a, b) => b - a); //排序
this.rates.forEach((rate) => {
this.insertItem(rate % 1 == 0 ? rate + ".0x" : rate + "x", rate, (e) => {
e.stopPropagation();
const rateValue = e.target.getAttribute("data-value");
this.setVideoRate(rateValue);
this.setActiveRate(rateValue);
localStorage.setItem(CUSTOM_RATE, rateValue);
});
});
//插入一个设置按钮
this.insertItem("设置", 0, (e) => {
e.stopPropagation();
setting.create(this.saveSetting);
setting.show();
});
}
setActiveRate(rateValue) {
const items = this.menu.querySelectorAll(`.${MENUCLASS_ITEM}`);
items.forEach((item) => {
const value = item.getAttribute("data-value");
if (value === rateValue) {
item.classList.add(MENUCLASS_ACTIVE);
} else {
item.classList.remove(MENUCLASS_ACTIVE);
}
});
}
getDuration() {
return this.videoObj.duration;
}
getCurrentTime() {
return this.videoObj.currentTime;
}
setVideoRate(rate) {
this.videoObj.playbackRate = parseFloat(rate);
}
getVideoRate() {
return this.videoObj.playbackRate;
}
//使用此函数前提:速度列表必须存在该速度值
setRate(rate) {
const item = document.querySelector(`.${MENUCLASS_ITEM}[data-value="${rate}"]`);
if (item) {
item.classList.add(MENUCLASS_ACTIVE);
item.click();
} else {
console.error("未找到匹配元素");
}
}
changeRate(up) {
let nowRate = getRate();
let index = this.rates.indexOf(nowRate);
if ((index == 0 && up) || (index == this.rates.length && !up)) {
return nowRate;
} else {
index += up ? -1 : 1;
this.setRate(this.rates[index]);
return this.rates[index];
}
}
getVolume() {
if (this.videoObj.volume == 1.0) {
return this.gain.gain.value;
} else {
return this.videoObj.volume;
}
}
setVolume(volume, show) {
if (!this.gain && volume > 1.0) {
volume = 1.0;
}
if (volume <= 1.0) {
this.videoObj.volume = volume;
if (this.gain) {
this.gain.gain.value = 1;
}
} else {
this.videoObj.volume = 1;
this.gain.gain.value = volume;
}
let sv = (volume * 100).toFixed(0);
localStorage.setItem(CUSTOM_Volume, sv);
this.volumeNumElem.textContent = sv;
if (show == true) {
showVolume(sv);
}
}
}
let menu = new PlayRateMenu();
let _interval = setInterval(function () {
let element = document.querySelector(`.${MENUCLASS}`);
if (element) {
if (menu.init(element)) {
menu.render();
menu.setRate(getRate());
let bpx_player_video_warp = document.querySelector(".bpx-player-video-wrap");
bpx_player_video_warp.appendChild(tipDisplay);
bpx_player_video_warp.appendChild(timeDisplay);
if (setting.GlobalVolumeAdjustment) {
bpx_player_video_warp.addEventListener("mousewheel", (e) => {
e.preventDefault();
e.stopImmediatePropagation();
let volume = menu.getVolume() + parseInt(e.wheelDelta / 120) * 0.05;
if (volume > 5.0) {
volume = 5.0;
} else if (volume < 0) {
volume = 0;
}
menu.setVolume(volume, true);
});
}
setShowTimeState(localStorage.getItem(CUSTOM_ShowTimeState) == "true");
clearInterval(_interval);
} else {
console.warn("获取视频元素失败!");
}
//双击复制字幕内容
let subtitle_panel = document.querySelector(".bpx-player-subtitle-panel-major-group");
if (subtitle_panel) {
subtitle_panel.addEventListener("dblclick", function () {
let text = document.querySelector(".bpx-player-subtitle-panel-major-group span").textContent;
//如果是歌词会存在音乐符号,要清除
let musicSymbol = "♪";
if (text.startsWith(musicSymbol)) {
text = text.slice(musicSymbol.length);
if (text.endsWith(musicSymbol)) {
text = text.slice(0, -musicSymbol.length);
}
}
navigator.clipboard.writeText(text);
});
}
}
}, 500);
let ArrowRightTime = 0;
let OldRate = 0;
document.addEventListener(
"keydown",
function (e) {
e = e || window.event;
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.tagName == "BILI-COMMENTS" || e.target.isContentEditable) {
return;
}
if (e.ctrlKey == true && e.code == "ArrowUp") {
let rate = menu.changeRate(true);
showPlayRate(rate);
} else if (e.ctrlKey == true && e.code == "ArrowDown") {
let rate = menu.changeRate(false);
showPlayRate(rate);
} else if (e.code == "ArrowRight" && !e.ctrlKey && !e.shiftKey && !e.altKey) {
if (ArrowRightTime == 0) {
ArrowRightTime = e.timeStamp;
} else {
if (e.timeStamp - ArrowRightTime > 500) {
if (OldRate == 0) {
OldRate = getRate();
if (typeof setting.ArrowRightTime === "string" && setting.ArrowRightTime.indexOf("x") != -1) {
menu.setVideoRate(OldRate * parseInt(setting.ArrowRightTime));
showPlayRate(OldRate * parseInt(setting.ArrowRightTime));
} else {
menu.setVideoRate(parseInt(setting.ArrowRightTime));
showPlayRate(parseInt(setting.ArrowRightTime));
}
}
}
}
} else if ("0" <= e.key && e.key <= "9") {
e.preventDefault();
e.stopImmediatePropagation();
let num = parseInt(e.key - "0");
let speed;
if (setting.SwitchCustomSpeed) {
if (!(1 <= num && num <= menu.rates.length)) {
return;
}
speed = menu.rates[menu.rates.length - num];
} else {
if (num == 0) {
speed = 0.5;
} else {
speed = num;
}
}
if (e.ctrlKey) {
menu.setVideoRate(speed);
menu.setActiveRate(speed);
showPlayRate(speed);
localStorage.setItem(CUSTOM_RATE, speed);
} else {
if (OldRate == 0) {
OldRate = getRate();
menu.setVideoRate(speed);
showPlayRate(speed);
}
}
} else if (e.code == "KeyZ" && !e.ctrlKey && !e.shiftKey && !e.altKey) {
let subtitle_btn = document.querySelector("#bilibili-player .bpx-player-ctrl-subtitle span");
if (subtitle_btn) {
subtitle_btn.click();
}
} else if (e.code == "KeyG" && !e.ctrlKey && !e.shiftKey && !e.altKey) {
document.querySelector("#bilibili-player .bpx-player-ctrl-web span").click();
} else if (e.code == "KeyH" && !e.ctrlKey && !e.shiftKey && !e.altKey) {
document.querySelector("#bilibili-player .bpx-player-ctrl-wide span").click();
} else if ((e.code == "ArrowUp" || e.code == "ArrowDown") && !e.ctrlKey && !e.shiftKey && !e.altKey) {
if (setting.GlobalVolumeAdjustment) {
e.preventDefault();
e.stopImmediatePropagation();
let volume = menu.getVolume();
if (e.code == "ArrowUp") {
volume = volume + 0.1;
if (volume > 5.0) {
volume = 5.0;
}
} else {
volume = volume - 0.1;
if (volume < 0) {
volume = 0;
}
}
menu.setVolume(volume, true);
}
}
},
true
);
document.addEventListener("keyup", function (e) {
if (e.code == "ArrowRight" || ("0" <= e.key && e.key <= "9")) {
ArrowRightTime = 0;
if (OldRate != 0) {
menu.setVideoRate(OldRate);
showPlayRate(OldRate);
OldRate = 0;
e.preventDefault();
}
} else if (e.code == "F2") {
setShowTimeState(localStorage.getItem(CUSTOM_ShowTimeState) == "false");
}
});
window.addEventListener("focus", function () {
menu.setRate(getRate());
setShowTimeState(localStorage.getItem(CUSTOM_ShowTimeState) == "true");
if (setting.GlobalVolumeAdjustment) {
let volume = localStorage.getItem(CUSTOM_Volume);
if (volume != -1) {
menu.setVolume(volume / 100, false);
}
}
});
function formatTime(s) {
var m = parseInt(s / 60);
var ss = parseInt(s % 60);
return (m > 9 ? `${m}` : `0${m}`) + ":" + (ss > 9 ? `${ss}` : `0${ss}`);
}
function FlashShowTime() {
var rate = menu.getVideoRate();
timeDisplay.textContent = formatTime(menu.getCurrentTime() / rate) + "/" + formatTime(menu.getDuration() / rate);
}
})();