哔哩哔哩宽屏

哔哩哔哩宽屏体验

目前为 2023-10-14 提交的版本。查看 最新版本

// ==UserScript==
// @name            哔哩哔哩宽屏
// @name:en         Wider Bilibili
// @namespace       https://greasyfork.org/users/1125570
// @description     哔哩哔哩宽屏体验
// @description:en  BiliBili, but wider
// @version         0.3.1
// @author          posthumz
// @license         MIT
// @match           http*://*.bilibili.com/*
// @icon            https://www.bilibili.com/favicon.ico
// @run-at          document-end
// @grant           GM_addStyle
// @grant           GM_getValue
// @grant           GM_setValue
// @grant           GM_registerMenuCommand
// @grant           GM_addValueChangeListener
// @noframes
// ==/UserScript==

(async () => {
	'use strict'

	const styles = {
		home:
`.feed-card, .floor-single-card, .bili-video-card {
	margin-top: 0px !important;
}`,

		t:
`.bili-dyn-home--member {
	margin: 0 var(--layout-padding) !important;

	main {
		flex: 1
	}
}
`,

		read:
`.article-detail {
	width: 90%;

	.article-up-info {
		width: initial;
		margin: 0 80px 20px;
	}
	.right-side-bar {
		right: 0;
	}
}`,

		video:
`/* 播放器 */
#bilibili-player {
	position: relative;
	z-index: 1;
	width: 100%;
	height: 100%;
}

#playerWrap,
#bilibili-player-wrap {
	position: relative;
	height: 100vh;
	min-height: 20vh;
	padding: 0;
}

/* 小窗 */
.bpx-player-container[data-screen="mini"] {
	height: auto !important; /* 以视频长宽比为准,且不显示黑边 */
	transform: translateY(24px) !important;
}

.bpx-player-container:not([data-screen="mini"]) {
	width: 100% !important;
}

/* 视频标题换行显示 */
#viewbox_report {
	height: auto;
}

.video-title {
	white-space: normal !important;
}

/* 视频页, 番剧页, 收藏(包括稍后再看)页 下方容器 */
.video-container-v1, .main-container, .playlist-container {
	z-index: 0;
	padding: 0 var(--layout-padding);
}

.left-container, .plp-l, .playlist-container--left {
	flex: 1;
}

.plp-r {
	padding-top: 0 !important;
}

/* 番剧/影视页下方容器 */
.main-container {
	width: 100%;
	margin: 0;
	padding: 15px 50px 15px 25px !important;
	box-sizing: border-box;
	display: flex;
}

.player-left-components {
	padding-right: 30px !important;
}

.toolbar {
	padding-top: 0;
}

/* 播放器控件 */
.bpx-player-top-left-title, .bpx-player-top-left-music {
	display: block !important;
}

.bpx-player-control-bottom {
	padding: 0 24px;
}

.bpx-player-control-bottom-left,
.bpx-player-control-bottom-right,
.bpx-player-sending-bar,
.be-video-control-bar-extend {
	gap: 12px;

	>:not(.bpx-player-ctrl-viewpoint, .bpx-player-ctrl-time) {
		width: auto !important;
	}
}

.bpx-player-ctrl-viewpoint {
	margin: 0;
}

.bpx-player-control-bottom-center {
	padding: 0 !important;
	display: flex;
	justify-content: center;
}

.bpx-player-sending-bar {
	margin: 0 !important;
}

.bpx-player-control-bottom-right {
	min-width: initial !important;
}

.bpx-player-ctrl-time-label {
	text-align: center !important;
	text-indent: 0 !important;
}

.bpx-player-ctrl-time, .bpx-player-ctrl-quality {
	margin-right: 0 !important;
}

.bpx-player-video-info {
	margin-right: 0 !important;
	display: flex !important;
}

/* 右下方浮动按钮 */
div[class^=navTools_floatNav] {
	z-index: 1 !important;
}

/* 笔记位移 (不然笔记会超出页面初始范围) */
.note-pc {
	transform: translate(-12px, 64px);
}

/* 导航栏 (兼容Bilibili Evolved自定义导航栏) */
#biliMainHeader, .custom-navbar {
	position: sticky !important;
	top: 0;
	z-index: 3 !important;
}

#biliMainHeader > .bili-header {
	min-height: 0 !important;
}

.bili-header__bar {
	position: relative !important;
}

/* Bilibili Evolved 夜间模式修正 */
.bpx-player-container .bpx-player-sending-bar {
	background-color: transparent !important;
}

.bpx-player-container .bpx-player-video-info {
	color: hsla(0,0%,100%,.9) !important;
}

.bpx-player-container .bpx-player-sending-bar .bpx-player-video-btn-dm,
.bpx-player-container .bpx-player-sending-bar .bpx-player-dm-setting,
.bpx-player-container .bpx-player-sending-bar .bpx-player-dm-switch {
	fill: hsla(0,0%,100%,.9) !important;
}

/* Bilibili Evolved侧栏 */
.be-settings {
	z-index: 3;
	position: fixed;
}`,

		mini:
`.bpx-player-container {
	min-width: 180px;
}
.bpx-player-mini-resizer {
	position: absolute;
	left: 0;
	width: 10px;
	height: 100%;
	cursor: ew-resize;
}`,

		general:
`:root {
	--layout-padding: ${GM_getValue('左右边距', 30)}px;
}

/* 搜索栏 */
.center-search-container {
	min-width: 0;
}

.nav-search-input {
	padding-right: 0;
}

.nav-search-clean {
	display: none;
}

/* 脚本设置样式 */
#WBOptions {
	position: fixed;
	left: 50%;
	top: 50%;
	transform: translate(-50%, -50%);
	z-index: 114514;

	border-radius: 15px;
	padding: 20px;
	display: none;
	grid-template-columns: repeat(2, 1fr);
	gap: 20px 30px;

	background-color: var(--bg1);
	color: var(--text1);
	box-shadow: 0 0 4px #00a0d8;
	font-size: 18px;
}

#WBOptionsClose {
	position: absolute;
	border: none;
	right: 0;
	font-size: 30px;
	line-height: 30px;
	width: 30px;
	border-top-right-radius: 15px;
	border-bottom-left-radius: 5px;
	transition: .1s;
	background-color: transparent;
	color: var(--text1);
}

#WBOptionsClose:hover {
	background-color: #e81123;
}

#WBOptionsClose:active {
	opacity: 0.5;
}

#WBOptions>header {
	grid-column: 1/-1;
}

#WBOptions>label {
	align-items: center;
	display: flex;
	gap: 10px;
}

#WBOptions input {
	height: 20px;
	margin: 0;
	padding: 4px !important;
	box-sizing: content-box !important;
}

.slider::before {
	content: "";
	display: block;
	position: relative;

	height: 100%;
	aspect-ratio: 1/1;
	border-radius: 50%;
	background-color: #fff;
	transition: .4s;
}

.slider {
	appearance: none;
	width: 40px;
	border-radius: 20px;
	box-sizing: content-box;
	cursor: pointer;
	background-color: #ccc;
	transition: .4s;
}

.slider:checked::before {
	transform: translateX(20px);
}

.slider:checked {
	background-color: #00a0d8;
}

.slider:hover {
	box-shadow: 0 0 4px #00a0d8;
}

.slider:active {
	opacity: 0.5;
}

#WBOptions input[type=number] {
	width: 60px;
	border: none;
	border-radius: 5px;
	background: transparent;
	box-shadow: 0 0 0 2px #00a0d8;
	color: var(--text1) !important;
}`}

	GM_addStyle(styles.general)

	// /** @type Map<string, Function> */
	// const callbacks = new Map()

	// 设置选项功能
	const options = document.body.appendChild(document.createElement('div'))
	options.id = 'WBOptions'
	options.innerHTML =`<button id="WBOptionsClose">×</button>
<header>⚙️宽屏选项</header>
<label><input type="checkbox" class="slider"${GM_getValue('导航栏下置', true) ? ' checked': ''}>导航栏下置</label>
<label><input type="number" placeholder="px" value="${GM_getValue('左右边距', 30)}">左右边距</label>`

	GM_registerMenuCommand('选项', () => { options.style.display = 'grid' })
	options.getElementsByTagName('button')[0]?.addEventListener('click', () => { options.style.display = 'none' })

	for (const Element of options.children) {
		if (Element instanceof HTMLLabelElement) {
			const input = Element.getElementsByTagName('input')[0]
			const key = Element.textContent ?? ''
			switch (input?.type) {
			case null:
			default:
				console.error('啊?')
				break
			case 'checkbox':
				input.onchange = () => {
					GM_setValue(key, input.checked)
				}
				break
			case 'number':
				input.oninput = () => {
					const val = Number(input.value)
					if (Number.isInteger(val)) {
						GM_setValue(key, val)
					}
				}
				break
			}
		}
	}

	GM_addValueChangeListener('左右边距', (_n, _o, newVal) =>
		document.documentElement.style.setProperty('--layout-padding', `${newVal}px`))

	// 每一定时间检测某个条件是否满足,超时则reject
	const waitFor = (/** @type {() => boolean}*/ loaded, retry = 100, interval = 100) =>
		new Promise((resolve, reject) => {
			const intervalID = setInterval(() => {
				if (--retry == 0) {
					console.error('页面加载超时')
					clearInterval(intervalID)
					return reject()
				}
				if (loaded()) {
					clearInterval(intervalID)
					return resolve(null)
				}
				if (retry % 10 == 0) { console.debug('等待页面加载') }
			}, interval)
		})

	const url = new URL(window.location.href)
	switch (url.host) {
	case 't.bilibili.com':
		GM_addStyle(styles.t)
		console.info('使用动态样式')
		break

	case 'www.bilibili.com': {
		// #region 首页
		if (document.getElementById('i_cecream')) {
			GM_addStyle(styles.home)
			console.info('使用首页宽屏样式')
			break
		}
		// #endregion

		// #region 阅读页
		if (document.getElementsByClassName('article-detail')[0]) {
			GM_addStyle(styles.read)
			console.info('使用阅读页宽屏样式')
			break
		}
		// #endregion

		// #region 播放页
		// 播放器不存在时不执行
		const player = document.getElementById('bilibili-player')
		if (!player) { return console.info('未找到播放器,不启用宽屏模式') }

		// 主容器,视频播放页为#app,番剧/影视播放页为.home-container
		const home = document.getElementById('app') ?? document.getElementsByClassName('home-container')[0]
		// 播放器外容器,视频播放页为#playerWrap,番剧/影视播放页为#bilibili-player-wrap
		const wrap = document.getElementById('playerWrap') ?? document.getElementById('bilibili-player-wrap')
		// 在新版本页面,播放器存在时都应该存在
		if (!wrap || !home) { return console.error(
			`页面加载错误:${[
				wrap ? '' : '播放器外容器',
				home ? '' : '主容器',
			].filter(Boolean).join(', ')},请检查是否为新版页面`
		) }

		// 等待人数加载
		const b = player.getElementsByTagName('b')
		await waitFor(() => b[0]?.textContent != null)
		await waitFor(() => b[0]?.textContent != '-')

		// 导航栏 (兼容Bilibili Evolved自定义顶栏,有可能延后加载)
		const navigation = await (async () => {
			const header = document.getElementById('biliMainHeader')
			if (header) {
				header.style.setProperty('height', 'initial', 'important')
				// 将导航栏移至主容器最前

				home.insertAdjacentElement('afterbegin', header)
				// bili-header__bar不可见时使用自定义顶栏
				const headerBar = header.getElementsByClassName('bili-header__bar')[0]
				if (headerBar && window.getComputedStyle(headerBar).display == 'none') {
					const navbar = document.getElementsByClassName('custom-navbar')
					await waitFor(() => Boolean(navbar[0]))
					return home.insertAdjacentElement('afterbegin', navbar[0])
				}
			}
			return header
		})()
		// 播放器内容器
		const container = player.getElementsByClassName('bpx-player-container')[0]
		// 播放器底中部框 (用于放置弹幕框内容)
		const bottomCenter = (() => {
			const center = player.getElementsByClassName('bpx-player-control-bottom-center')[0]
			// 番剧版使用squirtle-controller-wrap-center,但也存在bpx-player-control-bottom-center
			// 所以通过检测前一个元素(bpx-player-control-bottom-left)是否有子元素来判断使用哪个
			return center?.previousElementSibling?.hasChildNodes() ? center
				: player.getElementsByClassName('squirtle-controller-wrap-center')[0]
		})()
		// 弹幕框
		const danmaku = player.getElementsByClassName('bpx-player-sending-bar')[0]

		// 正常情况应该都存在
		if (!navigation || !(container instanceof HTMLDivElement) || !bottomCenter || !danmaku) {
			return console.error(
				`页面加载错误:${[
					navigation ? '' : '导航栏',
					container ? '' : '播放器内容器',
					bottomCenter ? '' : '播放器底中部框',
					danmaku ? '' : '弹幕框',
				].filter(Boolean).join(', ')}`
			)
		}

		// 改变导航栏位置,true为视频下方,false为视频上方,默认为下方
		const lowerNavigation = (value = true) => {
			if (value) {
				wrap.style.removeProperty('height')
				navigation.insertAdjacentElement('beforebegin', wrap)
			} else {
				wrap.style.height = `calc(100vh - ${navigation.clientHeight}px)`
				navigation.insertAdjacentElement('afterend', wrap)
			}
			return value
		}
		lowerNavigation(GM_getValue('导航栏下置', true))

		GM_addValueChangeListener('导航栏下置', (_n, _o, newVal) => { lowerNavigation(newVal) })

		// 使用宽屏样式 (除非当前是小窗模式)
		if (container.getAttribute('data-screen') != 'mini') {
			container.setAttribute('data-screen', 'web')
		}
		// 重载container的setAttribute:data-screen被设置为mini(小窗)以外的值时将其设置为web(宽屏)
		const setAttributeContainer = container.setAttribute.bind(container)
		container.setAttribute = (name, value) =>
			setAttributeContainer(name, name == 'data-screen' && value != 'mini' ? 'web' : value)
		// 番剧页面需要初始与退出全屏时移除#bilibili-player-wrap的class
		if (wrap.id == 'bilibili-player-wrap') {
			wrap.className = ''
			document.addEventListener('fullscreenchange',
				() => { document.fullscreenElement ?? (wrap.className = '') }
			)
		}
		// 退出全屏时弹幕框移至播放器下方
		document.addEventListener('fullscreenchange',
			() => { document.fullscreenElement ?? bottomCenter.replaceChildren(danmaku) }
		)

		// 移除原 宽屏/网页全屏 按钮,因为没有用了
		for (const className of [
			'bpx-player-ctrl-wide', 'bpx-player-ctrl-web',
			'squirtle-widescreen-wrap', 'squirtle-pagefullscreen-wrap',
		]) { player.getElementsByClassName(className)[0]?.remove() }

		// 添加视频样式
		GM_addStyle(styles.video)

		// 将弹幕框移至播放器下方一次
		bottomCenter.replaceChildren(danmaku)

		// 将笔记移至主容器,不然会被视频和导航栏遮挡
		const note = document.getElementsByClassName('note-pc')[0]
		if (note) {
			navigation.insertAdjacentElement('afterend', note)
		}

		console.info('宽屏模式成功启用')

		// #region 小窗
		GM_addStyle(styles.mini)

		const miniResizer = document.createElement('div')
		miniResizer.className = 'bpx-player-mini-resizer'
		miniResizer.onmousedown = (ev) => {
			ev.stopImmediatePropagation()
			ev.preventDefault()

			const resize = (/** @type MouseEvent */ ev) => {
				container.style.width = `${container.offsetWidth + container.offsetLeft - ev.x + 1}px`
			}
			document.addEventListener('mousemove', resize)
			document.addEventListener('mouseup', () => document.removeEventListener('mousemove', resize), {once: true})
		}

		container.getElementsByClassName('bpx-player-mini-warp')[0]?.appendChild(miniResizer) ?? (
			container.getElementsByClassName('bpx-player-video-area')[0] &&
			new MutationObserver((mutations, observer) => {
				mutations.filter(mutation => mutation.type == 'childList').forEach(mutation => {
					mutation.addedNodes.forEach(node => {
						if (node instanceof Element && node.classList.contains('bpx-player-mini-warp')) {
							node.appendChild(miniResizer)
							observer.disconnect()
						}
					})
				})
			}
			).observe(container.getElementsByClassName('bpx-player-video-area')[0], {childList: true})
		)
		// #endregion

		break
		// #endregion
	}

	default:
		console.info('未知页面')
		break
	}
})()