Merge pull request #2960 from timongh/feat-default-location

添加新组件“视频页默认定位”(videoDefaultLocation)
This commit is contained in:
Grant Howard 2022-02-13 09:47:02 +08:00 committed by GitHub
commit 22916eaef7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 771 additions and 2 deletions

View File

@ -92,6 +92,7 @@
"Violentmonkey",
"Vuex",
"Wasm",
"watchlater",
"woff",
"xmlhttp"
],

View File

@ -0,0 +1,166 @@
<template>
<div>
<div class="video-default-location-form-line">
<div class="video-default-location-form-item-not-grow">
当前位置
</div>
<TextBox
class="video-default-location-form-item-grow"
:text="String(curPosition)"
change-on-blur
readonly
linear
/>
</div>
<div class="video-default-location-vertical-space"></div>
<div class="video-default-location-form-line">
<VButton
class="video-default-location-form-item-grow"
@click="$emit('set-default-location', curPosition)"
>
将当前位置设为默认值
</VButton>
</div>
<div class="video-default-location-vertical-space"></div>
<div class="video-default-location-form-line">
<TextBox
v-model="locationInput"
class="video-default-location-form-item-grow"
linear
change-on-blur
@change="onLocationInput"
/>
<VButton @click="locateTo">
定位
</VButton>
</div>
<div class="video-default-location-vertical-space"></div>
<div class="video-default-location-form-line">
<TextBox
v-model="offsetInput"
class="video-default-location-form-item-grow"
linear
change-on-blur
@change="onOffsetInput"
/>
<VButton @click="offsetTo">
偏移
</VButton>
</div>
</div>
</template>
<script lang="ts">
import { VButton, TextBox } from '@/ui'
let scrollObserver = null
const getScrollY = (): number => Math.round(window.scrollY)
const stringIntoInt = (value: string): number | null => {
const num = parseFloat(value)
if (isNaN(num)) {
return null
}
return Math.round(num)
}
export default Vue.extend({
components: { VButton, TextBox },
props: {
observePosition: {
type: Boolean,
default: false,
},
locationLimit: {
type: Number,
required: true,
},
},
data() {
return {
curPosition: getScrollY(),
locationInput: '0',
offsetInput: '0',
location: 0,
offset: 0,
}
},
created() {
this.setupObserveScroll()
},
beforeDestroy() {
scrollObserver.stop()
},
methods: {
setLocation(value: number) {
this.location = value
this.locationInput = String(value)
},
onLocationInput(value: string) {
let num = stringIntoInt(value)
if (num === null) {
this.setLocation(0)
} else {
num = lodash.clamp(num, 0, this.locationLimit)
this.setLocation(num)
}
},
locateTo() {
unsafeWindow.scrollTo(0, this.location)
},
setOffset(value: number) {
this.offset = value
this.offsetInput = String(value)
},
onOffsetInput(value) {
let num = stringIntoInt(value)
if (num === null) {
this.setOffset(0)
} else {
num = lodash.clamp(num, -this.locationLimit, this.locationLimit)
this.setOffset(num)
}
},
offsetTo() {
unsafeWindow.scrollBy(0, this.offset)
},
setupObserveScroll() {
const updateCurPosition = () => {
this.curPosition = getScrollY()
}
let observing = false
scrollObserver = {
start: () => {
if (!observing) {
updateCurPosition()
window.addEventListener('scroll', updateCurPosition)
observing = true
}
},
stop: () => {
if (observing) {
window.removeEventListener('scroll', updateCurPosition)
observing = false
}
},
}
this.$watch(
'observePosition',
shouldObserve => scrollObserver[shouldObserve ? 'start' : 'stop'](),
{ immediate: true },
)
},
},
})
</script>
<style lang="scss">
</style>

View File

@ -0,0 +1,159 @@
<template>
<div
class="video-default-location-extend-box"
:class="{ 'video-default-location-extend-box-hidden': realHidden }"
>
<div class="video-default-location-extend-box-bar" @click="setRealHidden">
<div class="video-default-location-extend-box-bar-text">
位置测试
</div>
<div
class="video-default-location-extend-box-bar-btn"
:class="btnClass"
@animationend="onBarBtnAnimationEnd"
>
<VIcon :icon="btnIcon" :size="15" />
</div>
</div>
<div class="video-default-location-extend-box-content-wrap">
<transition name="video-default-location-extend-box-content-transition">
<div v-show="!realHidden" class="video-default-location-extend-box-content">
<slot></slot>
</div>
</transition>
</div>
</div>
</template>
<script lang="ts">
import { VIcon } from '@/ui'
const getIconName = (hidden: boolean): string => (hidden ? 'mdi-unfold-more-horizontal' : 'mdi-unfold-less-horizontal')
const btnAnimationClass = 'video-default-location-extend-box-bar-btn-animation'
export default Vue.extend({
components: { VIcon },
model: {
prop: 'hidden',
event: 'change',
},
props: {
title: {
type: String,
default: '',
},
size: {
type: Number,
default: 12,
},
hidden: {
type: Boolean,
default: true,
},
},
data() {
return {
realHidden: this.hidden,
barBottom: !this.hidden,
btnIcon: getIconName(this.hidden),
btnClass: {
[btnAnimationClass]: false,
},
}
},
watch: {
hidden(value: boolean) {
this.setRealHidden(value)
},
},
methods: {
setRealHidden(value: boolean) {
if (value !== this.realHidden) {
this.realHidden = !this.realHidden
this.$emit('change', this.realHidden)
this.btnClass[btnAnimationClass] = false
this.$nextTick(() => {
this.btnClass[btnAnimationClass] = true
setTimeout(() => {
this.btnIcon = getIconName(this.realHidden)
}, 150)
})
}
},
onBarBtnAnimationEnd() {
this.btnClass[btnAnimationClass] = false
},
},
})
</script>
<style lang="scss">
@import "bar";
$border-color: #8884;
$border-radius: 4px;
.video-default-location-extend-box {
border-radius: $border-radius;
box-shadow: 0 0 0 1px $border-color;
}
.video-default-location-extend-box-bar {
display: flex;
justify-content: space-between;
align-items: center;
border-radius: $border-radius;
box-shadow: 0 1px $border-color;
cursor: pointer;
}
.video-default-location-extend-box-bar-text {
@include title-container;
}
.video-default-location-extend-box-bar-btn {
@include icon-container;
}
.video-default-location-extend-box-bar-btn-animation {
animation: video-default-location-extend-box-bar-btn-animation-keyframes 0.3s;
}
@keyframes video-default-location-extend-box-bar-btn-animation-keyframes {
50% {
transform: rotateX(90deg);
}
}
.video-default-location-extend-box-bar {
transition: box-shadow .2s ease-out;
}
.video-default-location-extend-box-hidden {
.video-default-location-extend-box-bar {
box-shadow: 0 0 $border-color;
}
}
.video-default-location-extend-box-content-wrap {
overflow: hidden;
}
.video-default-location-extend-box-content-transition-enter-active,
.video-default-location-extend-box-content-transition-leave-active {
transition: margin-top .2s ease-out;
}
.video-default-location-extend-box-content-transition-enter,
.video-default-location-extend-box-content-transition-leave-to {
margin-top: -100%;
}
// .video-default-location-extend-box-content-transition-enter-to,
// .video-default-location-extend-box-content-transition-leave {
// margin-top: 0;
// }
</style>

View File

@ -0,0 +1,163 @@
<template>
<div class="video-default-location-options">
<div class="video-default-location-form-line">
<div class="video-default-location-form-item-not-grow">
页面
</div>
<PageTypeSelector
v-model="pageType"
class="video-default-location-form-item-grow"
@change="onChangePageType"
/>
</div>
<div class="video-default-location-vertical-space"></div>
<div class="video-default-location-form-line">
<div class="video-default-location-form-item-not-grow">
默认位置
</div>
<TextBox
v-model="defaultLocation"
class="video-default-location-form-item-grow"
linear
change-on-blur
@change="onChangeDefaultLocation"
/>
</div>
<div class="video-default-location-vertical-space"></div>
<div class="video-default-location-options-test">
<ExtendBox v-model="hiddenAdvance" @change="resetObservePosition">
<div class="video-default-location-options-advanced">
<Advanced
:observe-position="observePosition"
:location-limit="locationLimit"
@set-default-location="setDefaultLocation"
/>
</div>
</ExtendBox>
</div>
</div>
</template>
<script lang="ts">
import { getComponentSettings } from '@/core/settings'
import { TextBox } from '@/ui'
import ExtendBox from './ExtendBox.vue'
import Advanced from './Advanced.vue'
import PageTypeSelector from './PageTypeSelector.vue'
import { pageTypeInfos, getCurrentPageType } from '.'
const maxLocation = 4000
let panelObserver = null
const stringIntoInt = (value: string): number | null => {
const num = parseFloat(value)
if (isNaN(num)) {
return null
}
return Math.round(num)
}
export default Vue.extend({
components: {
TextBox,
ExtendBox,
Advanced,
PageTypeSelector,
},
props: {
componentData: {
type: Object, // ComponentMetadata
required: true,
},
},
data() {
const { options: { locations } } = getComponentSettings(this.componentData)
const currentPageType = getCurrentPageType() ?? Object.keys(pageTypeInfos)[0]
return {
locations,
defaultLocation: String(locations[currentPageType]),
hiddenAdvance: true,
observePosition: false,
locationLimit: maxLocation,
pageType: currentPageType,
}
},
created() {
this.setupPanelSwitch()
},
mounted() {
if (panelObserver) {
panelObserver.start()
}
},
beforeDestroy() {
if (panelObserver) {
panelObserver.stop()
}
},
methods: {
onChangePageType(value: string) {
this.defaultLocation = String(this.locations[value])
},
setDefaultLocation(value: number) {
this.locations[this.pageType] = value
this.defaultLocation = String(value)
},
onChangeDefaultLocation(value: string) {
let num = stringIntoInt(value)
if (num === null) {
this.setDefaultLocation(0)
} else {
num = lodash.clamp(num, 0, maxLocation)
this.setDefaultLocation(num)
}
},
resetObservePosition() {
this.observePosition = !this.hiddenAdvance
},
//
setupPanelSwitch() {
const panel = dq('.component-detail-panel')
if (!panel) {
console.error(
"[videoPageOrientation] Could not find element '.component-detail-panel'",
)
return
}
const callback = () => {
if (panel.classList.contains('open')) {
this.resetObservePosition()
} else {
this.observePosition = false
}
}
const mutationObserver = new MutationObserver(callback)
const options = {
attributeFilter: ['class'],
attributes: true,
}
panelObserver = {
start: () => {
callback()
mutationObserver.observe(panel, options)
},
stop: () => mutationObserver.disconnect(),
}
},
},
})
</script>
<style lang="scss">
@import 'form';
.video-default-location-options-advanced {
margin: 8px;
}
</style>

View File

@ -0,0 +1,66 @@
<template>
<div class="video-default-location-page-type-selector">
<VDropdown v-model="curItem" :items="items" @change="onChange">
<template #arrow>
<div class="video-default-location-page-type-selector-icon">
<VIcon :size="15" icon="mdi-chevron-down" />
</div>
</template>
</VDropdown>
</div>
</template>
<script lang="ts">
import { VDropdown, VIcon } from '@/ui'
import { pageTypeInfos } from '.'
const itemsMap = lodash.mapValues(pageTypeInfos, (v, k) => ({
name: k,
displayName: v.displayName,
}))
interface Item {
name: string,
displayName: string,
}
export default Vue.extend({
components: { VDropdown, VIcon },
model: {
prop: 'value',
event: 'change',
},
props: {
value: {
type: String,
required: true,
},
},
data() {
return {
items: Object.values(itemsMap),
curItem: itemsMap[this.value],
}
},
watch: {
value(value: string) {
if (this.curItem.name !== value) {
this.curItem = itemsMap[value]
}
},
},
methods: {
onChange(item: string | Item) {
this.$emit('change', item.name)
},
},
})
</script>
<style lang="scss">
@import "bar";
.video-default-location-page-type-selector-icon {
@include icon-container;
}
</style>

View File

@ -0,0 +1,14 @@
@mixin title-container {
height: 24px;
line-height: 24px;
padding: 0 8px;
}
@mixin icon-container {
display: flex;
align-items: center;
justify-content: center;
height: 24px;
width: 24px;
color: #888a;
}

View File

@ -0,0 +1,18 @@
.video-default-location-form-line {
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: 10px;
}
.video-default-location-form-item-grow {
flex: 1 auto;
}
.video-default-location-form-item-not-grow {
flex: 0 auto;
}
.video-default-location-vertical-space {
height: 8px;
}

View File

@ -0,0 +1,18 @@
打开视频/番剧时自动定位到指定位置
<div class="video-default-location-desc-detail">
位置:距离页面顶部的像素距离\
默认位置:打开视频时自动定位到此处\
位置测试:查看、调整当前页面所在位置
当前版本限制默认位置的最大值为 4000。\
脚本不会等待评论完全加载,因此较大的默认位置将无法正确定位。
</div>
<style>
.video-default-location-desc-detail.video-default-location-desc-detail p {
margin-top: 1ex;
}
</style>

View File

@ -0,0 +1,155 @@
import { ComponentMetadata } from '@/components/types'
import {
allVideoUrls, bangumiUrls, bnjUrls, cheeseUrls, matchCurrentPage, mediaListUrls,
} from '@/core/utils/urls'
import { select } from '@/core/spin-query'
import desc from './desc.md'
const commonVideoUrlPattern = '//www.bilibili.com/video/'
export const pageTypeInfos = {
withTitle: {
displayName: '带标题视频页',
urls: [
commonVideoUrlPattern,
...mediaListUrls,
],
},
noTitle: {
displayName: '无标题视频页',
urls: [
...bangumiUrls,
...cheeseUrls,
],
},
bnj: {
displayName: '拜年纪视频页',
urls: [
...bnjUrls,
],
},
}
export const getCurrentPageType = lodash.once((): string | null => {
for (const [name, { urls }] of Object.entries(pageTypeInfos)) {
if (matchCurrentPage(urls)) {
return name
}
}
return null
})
interface WaitMoment {
time: number
callback: (time: number) => Promise<void>
}
class WaitResult<R> {
constructor(
// promise 完成前经过的最后一个 moments 中的时刻,
// 如果没有任何一个时刻经过,该值为 0
public lastMoment: number,
// promise 的返回值
public result: R,
) {}
}
// 等待 promise 执行完成。当经过 moments 所指定的时刻时,调用对应的函数。
// moments 的时刻必须是递增顺序
async function waitWithMoments<R>(
promise: Promise<R>,
moments: Iterable<WaitMoment>,
): Promise<WaitResult<R>> {
let lastMoment = { time: 0, callback: none as unknown } as WaitMoment
let timeoutId = null
const momentIt = moments[Symbol.iterator]()
const setNextMoment = () => {
const yielded = momentIt.next()
if (!yielded.done) {
const nextMoment = yielded.value
timeoutId = setTimeout(() => {
timeoutId = null
lastMoment.callback(lastMoment.time)
lastMoment = nextMoment
setNextMoment()
}, nextMoment.time - lastMoment.time)
}
}
setNextMoment()
const result = await promise
timeoutId !== null && clearTimeout(timeoutId)
return new WaitResult(lastMoment.time, result)
}
// 等待评论区元素加载,以提供足够的高度
// 在特定的时刻输出 warn 日志。超时时输出 error 日志。
// 返回元素是否在超时前加载完成
const waitComment = async (): Promise<boolean> => {
const minute = 60_000
const promise = select('.bb-comment', { maxRetry: 50, queryInterval: 600 })
const moments = [minute / 2, minute, 3 * minute].map(time => ({
time,
callback: (async theTime => {
console.warn(`[videoDefaultLocation] waiting more than ${theTime}ms for the page to load`)
}) as WaitMoment['callback'],
} as WaitMoment))
const res = (await waitWithMoments(promise, moments)).result
if (res === null) {
console.error('[videoDefaultLocation] waiting for page load timeout')
return false
}
return true
}
const entry = async ({ settings: { options: { locations } } }) => {
// 仅在初次进入页面时进行定位,即刷新、回退等操作不会执行定位
const navigationArr = window?.performance?.getEntriesByType('navigation')
if (navigationArr?.length !== 1) {
console.error(`[videoDefaultLocation] 无法处理 PerformanceNavigationTiming 不是一个的情况。url: ${window.location.href}`)
return
}
const nav = navigationArr[0] as PerformanceNavigationTiming
if (nav.type !== 'navigate') {
return
}
if (matchCurrentPage(commonVideoUrlPattern)) {
// 屏蔽初始化时的滚动行为
const org = unsafeWindow.scrollTo
unsafeWindow.scrollTo = () => {
unsafeWindow.scrollTo = org
}
}
// 获取当前页面类型的默认位置
const pageType = getCurrentPageType()
if (pageType === null) {
console.error(`[videoDefaultLocation] unknown page type. url: ${window.location.href}`)
return
}
const defaultLocation = locations[pageType]
// 滚动到默认位置
const html = document.documentElement
if (defaultLocation < html.scrollHeight - html.clientHeight || await waitComment()) {
window.scrollTo(0, defaultLocation)
}
}
export const component: ComponentMetadata = {
name: 'videoDefaultLocation',
displayName: '视频页默认定位',
tags: [componentsTags.video],
urlInclude: allVideoUrls,
description: { 'zh-CN': desc },
extraOptions: () => import('./Options.vue').then(m => m.default),
options: {
locations: {
defaultValue: lodash.mapValues(pageTypeInfos, () => 0),
hidden: true,
},
},
entry: entry as any,
}

View File

@ -3,21 +3,30 @@ import { TestPattern } from '../common-types'
/** 稍后再看页面 */
export const watchlaterUrls = [
'//www.bilibili.com/medialist/play/watchlater/',
'//www.bilibili.com/medialist/play/watchlater',
]
/** 收藏夹连播页面 */
export const favoriteListUrls = [
'//www.bilibili.com/medialist/play/ml',
]
/** UP 主视频连播页面 */
export const upListUrls = [
/\/\/www\.bilibili\.com\/medialist\/play\/\d+/,
]
/** 合集类页面 */
export const mediaListUrls = [
...watchlaterUrls,
...favoriteListUrls,
...upListUrls,
]
/** 拜年纪页面 */
export const bnjUrls = [
/\/\/www\.bilibili\.com\/festival\/(\d+)bnj/,
]
/** 含有普通视频的页面 */
export const videoUrls = [
'//www.bilibili.com/video/',
/\/\/www\.bilibili\.com\/festival\/(\d+)bnj/,
...bnjUrls,
...mediaListUrls,
]
/** 含有番剧的页面 */