Merge branch 'preview-features' into preview

This commit is contained in:
the1812 2025-06-29 15:38:41 +08:00
commit af2906e641
5 changed files with 473 additions and 2 deletions

View File

@ -0,0 +1,70 @@
<template>
<VButton :class="['bigger-video-preview-button', btnClass]" style="padding: 0" @click="onClick">
<VIcon :icon="enlarged ? 'mdi-magnify-minus' : 'mdi-magnify-plus'" :size="16"></VIcon>
</VButton>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { VIcon, VButton } from '@/ui'
export default defineComponent({
name: 'PreviewButton',
components: {
VIcon,
VButton,
},
props: {
btnClass: {
type: String,
required: true,
},
btnOnClickCallback: {
type: Function,
required: true,
},
enlarged: {
type: Boolean,
default: false,
},
},
methods: {
onClick(event: MouseEvent) {
this.btnOnClickCallback(event)
},
},
})
</script>
<style lang="scss">
//
.bigger-video-preview-button {
opacity: 0;
transition: opacity 0.3s ease;
position: absolute;
top: 6px;
left: 6px;
background-color: aqua;
height: 28px;
width: 28px;
z-index: 9;
}
.bili-video-card__image--wrap:hover .bigger-video-preview-button-index {
opacity: 1;
}
//
.bigger-video-preview-button-index {
@extend .bigger-video-preview-button;
}
.pic-box:hover .bigger-video-preview-button-video {
opacity: 1;
}
//
.bigger-video-preview-button-video {
@extend .bigger-video-preview-button;
}
</style>

View File

@ -0,0 +1,94 @@
<template>
<VPopup
ref="popup"
v-model="showPopup"
class="bigger-video-preview-video-container"
@popup-change="onPopupChange"
>
</VPopup>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { VPopup } from '@/ui'
export default defineComponent({
name: 'VideoContainer',
components: {
VPopup,
},
model: {
prop: 'showPopup',
event: 'popup-change',
},
data() {
return {
showPopup: false,
movedDom: null as HTMLElement | null,
originalParent: null as Node | null,
originalNextSibling: null as Node | null,
}
},
methods: {
openPopup(dom: HTMLElement) {
this.restoreDom()
if (!dom) {
return
}
//
this.originalParent = dom.parentNode
this.originalNextSibling = dom.nextSibling
this.togglePopup()
// popup
this.$nextTick(() => {
const popup = this.$refs.popup as any
if (popup && dom.parentNode !== popup) {
popup.$el.appendChild(dom)
this.movedDom = dom
}
})
},
closePopup() {
this.restoreDom()
this.togglePopup()
},
restoreDom() {
if (this.movedDom && this.originalParent) {
if (this.originalNextSibling) {
this.originalParent.insertBefore(this.movedDom, this.originalNextSibling)
} else {
this.originalParent.appendChild(this.movedDom)
}
this.movedDom = null
this.originalParent = null
this.originalNextSibling = null
}
},
onPopupChange(val: boolean) {
if (!val) {
this.restoreDom()
}
this.$emit('popup-change', val)
},
togglePopup() {
// VPopuptoggle
const popup = this.$refs.popup as any
if (popup && typeof popup.toggle === 'function') {
popup.toggle()
}
},
},
})
</script>
<style lang="scss">
.bigger-video-preview-video-container {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 99999;
}
</style>

View File

@ -0,0 +1,4 @@
放大鼠标悬停于视频卡片时播放的5分钟预览
- 点击放大镜图标放大预览
- 点击缩小图标或者预览框外空白处关闭预览

View File

@ -0,0 +1,303 @@
import Vue from 'vue'
import { ComponentEntry } from '@/components/types'
import { defineComponentMetadata } from '@/components/define'
import { childListSubtree } from '@/core/observer'
import { select } from '@/core/spin-query'
import { useScopedConsole } from '@/core/utils/log'
import PreviewButton from './PreviewButton.vue'
const logger = useScopedConsole('biggerVideoPreview')
const entry: ComponentEntry = async ({ settings }) => {
// 预览容器
let videoContainer = null
// #region functions
/**
*
*/
async function initPreviewContainer() {
const VideoContainerModule = await import('./VideoContainer.vue')
const VideoContainer = VideoContainerModule.default
const ModalClass = Vue.extend(VideoContainer)
videoContainer = new ModalClass()
videoContainer.$mount()
document.body.appendChild(videoContainer.$el)
}
/**
* Vue
* @param {string} className -
* @returns {HTMLElement}
*/
function createPreviewButton(className: string): HTMLElement {
// #region 局部定义
/**
*
* @param show
* @param btn
*/
const toggleButtonIcon = (show: boolean, btn: any) => {
btn.enlarged = show
}
/**
*
* @param {MouseEvent} event -
*/
const onMouseExitHandler = (event: MouseEvent) => {
event.stopPropagation()
}
/**
*
* @param {boolean} show -
* @param {Element} movingDom - DOM
*/
const toggleMouseExitHandler = (show: boolean, movingDom: Element) => {
if (settings.options.blockMouseExitHandler && show) {
movingDom.addEventListener('mouseleave', onMouseExitHandler, true)
} else {
movingDom.removeEventListener('mouseleave', onMouseExitHandler, true)
// 触发mouseleave事件停止预览
const mouseLeaveEvent = new MouseEvent('mouseleave')
movingDom.dispatchEvent(mouseLeaveEvent)
}
}
/**
*
* @param {Element} movingDom - DOM
* @param {boolean} show -
*/
const toggleVideoControls = (movingDom: Element, show: boolean) => {
const video = movingDom.querySelector('video')
// 首页视频卡片的时间条
const controlWrap = movingDom.querySelector('.bpx-player-control-wrap') as HTMLElement
if (video) {
if (settings.options.showVideoControls && show) {
video.controls = true
video.style.pointerEvents = 'visible'
if (controlWrap) {
controlWrap.style.display = 'none'
}
} else {
video.controls = false
video.style.pointerEvents = 'none'
if (controlWrap) {
controlWrap.style.display = 'unset'
}
}
}
}
/**
*
* @param {Event} event -
*/
const onTimeUpdateHandler = (event: Event) => {
event.stopImmediatePropagation()
}
/**
*
* @param {Element} movingDom - DOM
*/
const togglePreviewTimeLimit = (movingDom: Element) => {
if (settings.options.removePreviewTimeLimit) {
const video = movingDom.querySelector('video')
if (video) {
video.removeEventListener('timeupdate', onTimeUpdateHandler, true)
video.addEventListener('timeupdate', onTimeUpdateHandler, true)
}
} else {
const video = movingDom.querySelector('video')
if (video) {
video.removeEventListener('timeupdate', onTimeUpdateHandler, true)
}
}
}
// 提升 popupChangeHandler 到外部作用域,确保引用一致
let popupChangeHandler: (show: boolean) => void
// #endregion
// 创建 Vue 实例
const ComponentClass = Vue.extend(PreviewButton)
const instance = new ComponentClass({
propsData: {
btnClass: className,
btnOnClickCallback: (e: MouseEvent) => {
e.preventDefault()
if (!videoContainer) {
return
}
const movingDom = instance.$el.parentElement.closest(
'.bili-video-card__image--wrap,.pic-box',
)
// 监听弹窗状态变化事件
if (!popupChangeHandler) {
popupChangeHandler = (show: boolean) => {
// 切换按钮图标
toggleButtonIcon(show, instance)
// 切换鼠标移出事件监听
toggleMouseExitHandler(show, movingDom)
// 切换视频控件显示
toggleVideoControls(movingDom, show)
// 切换预览时长限制
togglePreviewTimeLimit(movingDom)
if (!show) {
videoContainer.$off('popup-change', popupChangeHandler)
}
}
}
// 根据当前状态切换预览
if (instance.enlarged) {
videoContainer.closePopup()
} else {
videoContainer.$el.style.width = `${settings.options.popupWidth}%`
videoContainer.$off('popup-change', popupChangeHandler)
videoContainer.$on('popup-change', popupChangeHandler)
videoContainer.openPopup(movingDom)
}
},
},
}) as Vue & { enlarged: boolean }
instance.$mount()
return instance.$el as HTMLElement
}
/**
*
* @param {HTMLElement} node -
* @param {string} className -
*/
function insertPreviewButton(node: HTMLElement, className: string) {
const wrap = node.querySelectorAll('.v-inline-player,.v-recommend-inline-player')
wrap.forEach(it => {
// 检查父元素下是否已经有该按钮,避免重复插入
if (it.parentElement.querySelector(`.${className}`)) {
return
}
const div = createPreviewButton(className)
it.parentElement.appendChild(div)
})
}
/**
*
* @param btnClassName
* @param videoClassName
* @param containerSelector
* @param getInsertNode
*/
async function initPreviewButton(
btnClassName: string,
videoClassName: string,
containerSelector: string,
getInsertNode: (element: HTMLElement) => HTMLElement,
) {
const containerNode = (await select(containerSelector)) as HTMLElement
if (!containerNode) {
logger.warn('未找到容器节点:', containerSelector)
return
}
const wrap = containerNode.querySelectorAll(`.${videoClassName}`)
logger.debug(`初始化已有卡片: `, wrap.length)
// 初始化已有卡片
insertPreviewButton(containerNode, btnClassName)
// 监听容器变化
childListSubtree(containerNode, records => {
records.forEach(record => {
record.addedNodes.forEach(n => {
if (n.nodeType !== Node.ELEMENT_NODE) {
return
}
const element = n as HTMLElement
if (
!element.classList.contains(videoClassName) &&
element.querySelector(`.${videoClassName}`) === null
) {
return
}
logger.debug('初始化卡片')
// 初始化卡片
insertPreviewButton(getInsertNode(element), btnClassName)
})
})
})
}
// #endregion
// 初始化预览容器
logger.debug('初始化预览容器')
await initPreviewContainer()
// 初始化预览放大按钮
if (document.URL.replace(window.location.search, '') === 'https://www.bilibili.com/') {
logger.debug('初始化首页预览放大按钮')
// 首页
initPreviewButton(
'bigger-video-preview-button-index',
'v-inline-player',
'.container',
element => element,
)
} else if (document.URL.startsWith('https://www.bilibili.com/video/')) {
logger.debug('初始化视频页预览放大按钮')
// 视频页
initPreviewButton(
'bigger-video-preview-button-video',
'v-recommend-inline-player',
'.recommend-list-v1',
element => element.parentElement,
)
}
}
export const component = defineComponentMetadata({
name: 'biggerVideoPreview',
displayName: '视频预览放大',
entry,
tags: [componentsTags.utils, componentsTags.video],
options: {
popupWidth: {
displayName: '弹窗宽度(%',
defaultValue: 90,
slider: {
min: 10,
max: 100,
step: 1,
},
},
blockMouseExitHandler: {
displayName: '鼠标移出弹窗不停止预览',
defaultValue: true,
},
showVideoControls: {
displayName: '显示视频控件',
defaultValue: true,
},
removePreviewTimeLimit: {
displayName: '解除5分钟预览限制弹幕会停止',
defaultValue: false,
},
},
})

View File

@ -133,9 +133,9 @@ export default Vue.extend({
const eventTypes = ['mousedown', 'touchstart']
eventTypes.forEach(type => {
if (this.open) {
document.documentElement.addEventListener(type, e => this.openHandler(e))
document.documentElement.addEventListener(type, this.openHandler)
} else {
document.documentElement.removeEventListener(type, e => this.openHandler(e))
document.documentElement.removeEventListener(type, this.openHandler)
}
})
}