跳到主要内容

西瓜视频封装

  • 支持水印,通过自定义插件
  • 支持自适应容器尺寸
  • 支持设置视频比例
  • 动态设置object-fit效果,如铺满效果、视频完整显示效果

useCustomPlayer​

ts
import { computed, ref, useAttrs, type Ref } from 'vue';
import HlsPlugin from 'xgplayer-hls';
import type { IPlayerOptions } from 'xgplayer';
import type Player from 'xgplayer';

export type CustomPlayerProps = {
width?: string | number;
height?: string | number;
url: string;
poster?: string;
autoplay?: boolean;
lang?: string;
cssFullscreen?: boolean;
loop?: boolean;
volume?: number | Record<string, unknown>;
wrapClass?: string;
aspectRatio?: AspectRatioEnum;
fitContainer?: boolean; // 是否自适应容器尺寸
keepAspectRatio?: boolean; // 是否保持视频比例
objectFit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down'; // 视频填充模式
};

export type CustomPlayerExpose = {
onPlay: () => void;
onPause: () => void;
togglePlay: () => void;
player: Ref<Player | undefined>;
refitToContainer: () => void;
};

// 宽高比
export enum AspectRatioEnum {
RATIO_16_9 = '16:9',
RATIO_9_16 = '9:16',
RATIO_4_3 = '4:3',
RATIO_3_4 = '3:4',
RATIO_1_1 = '1:1',
}

export const aspectRatioConfig = {
[AspectRatioEnum.RATIO_16_9]: 16 / 9,
[AspectRatioEnum.RATIO_9_16]: 9 / 16,
[AspectRatioEnum.RATIO_4_3]: 4 / 3,
[AspectRatioEnum.RATIO_3_4]: 3 / 4,
[AspectRatioEnum.RATIO_1_1]: 1 / 1,
};

export default function useCustomPlayer(props: CustomPlayerProps) {
const containerRef = ref<HTMLElement>();
const player = ref<Player>();
const attrs = useAttrs();

const playerConfig = computed(() => {
const plugins: ArrayType = [];

if (/\.m3u8/.test(props.url)) {
plugins.push(HlsPlugin);
}

// 如果启用自适应容器,则不传递 width 和 height
const config: IPlayerOptions = {
...props,
...attrs,
el: containerRef.value,
plugins,
};

// 如果启用自适应容器,移除 width 和 height 配置
if (props.fitContainer) {
delete config.width;
delete config.height;
}

return config;
});

return {
playerConfig,
player,
containerRef,
};
}

index.vue​

vue
<template>
<div ref="containerRef" class="custom-player" :style="containerStyle" />
</template>
<script setup lang="ts">
import { onBeforeUnmount, watch, computed, onMounted, nextTick } from 'vue';
import Player, { Events } from 'xgplayer';
import 'xgplayer/dist/index.min.css';
import useCustomPlayer, { aspectRatioConfig } from './hooks/useCustomPlayer';
import type { CustomPlayerExpose, CustomPlayerProps } from './hooks/useCustomPlayer';

defineOptions({
inheritAttrs: false,
});

const props = withDefaults(defineProps<CustomPlayerProps>(), {
lang: 'zh',
autoplay: true,
width: 400,
height: 400,
loop: true,
cssFullscreen: false, // 是否展示全屏按钮
fitContainer: false, // 是否自适应容器尺寸
keepAspectRatio: true, // 是否保持视频比例
objectFit: 'contain', // 视频填充模式
});

const emits = defineEmits(['play', 'pause', 'ended']);

const { containerRef, player, playerConfig } = useCustomPlayer(props);

// 容器样式计算
const containerStyle = computed(() => {
if (!props.fitContainer) {
return {};
}

const style: Record<string, string> = {
width: '100%',
height: '100%',
position: 'relative',
};

return style;
});

// 自适应容器尺寸
const fitToContainer = () => {
if (!props.fitContainer || !containerRef.value || !player.value) {
return;
}

const container = containerRef.value;
const videoElement = player.value.video as HTMLVideoElement;

if (!videoElement) {
return;
}

const containerWidth = container.offsetWidth;
const containerHeight = container.offsetHeight;

if (containerWidth === 0 || containerHeight === 0) {
return;
}

// 设置容器样式
container.style.width = '100%';
container.style.height = '100%';

// 设置视频元素基础样式
videoElement.style.width = '100%';
videoElement.style.height = '100%';
videoElement.style.objectFit = props.objectFit;
videoElement.style.objectPosition = 'center';

// 清除可能存在的 margin
videoElement.style.margin = '0';
videoElement.style.marginTop = '0';
videoElement.style.marginLeft = '0';

// 如果保持视频比例且使用 contain 模式,需要手动计算尺寸
if (
props.keepAspectRatio &&
props.objectFit === 'contain' &&
videoElement.videoWidth &&
videoElement.videoHeight
) {
const videoAspectRatio = videoElement.videoWidth / videoElement.videoHeight;
const containerAspectRatio = containerWidth / containerHeight;

// contain 模式:保持完整视频可见,可能有黑边
if (videoAspectRatio > containerAspectRatio) {
// 视频更宽,以宽度为准
const height = containerWidth / videoAspectRatio;
videoElement.style.width = '100%';
videoElement.style.height = `${height}px`;
videoElement.style.marginTop = `${(containerHeight - height) / 2}px`;
} else {
// 视频更高,以高度为准
const width = containerHeight * videoAspectRatio;
videoElement.style.width = `${width}px`;
videoElement.style.height = '100%';
videoElement.style.marginLeft = `${(containerWidth - width) / 2}px`;
}
}

// cover/fill 模式:让 CSS object-fit 自动处理即可
};

const initPlayer = () => {
player.value = new Player(playerConfig.value);

// 播放器创建完成
player.value.once(Events.COMPLETE, () => {
if (containerRef.value) {
// 如果启用自适应容器
if (props.fitContainer) {
fitToContainer();
} else {
// 保持原有的宽高比逻辑
if (props.aspectRatio && Reflect.has(aspectRatioConfig, props.aspectRatio)) {
const width =
containerRef.value.offsetWidth * aspectRatioConfig[props.aspectRatio] + 'px';
containerRef.value.style.width = width;
}
}
}

if (props.autoplay) {
player.value?.replay();
}
});

// 视频元数据加载完成
player.value.on(Events.LOADED_METADATA, () => {
if (props.fitContainer) {
fitToContainer();
}
});

// 播放
player.value.on(Events.PLAY, () => {
emits('play');
});
// 暂停
player.value.on(Events.PAUSE, () => {
emits('pause');
});
// 结束
player.value.on(Events.ENDED, () => {
emits('ended');
});

if (props.wrapClass) {
player.value.addClass(props.wrapClass);
}
};

// 监听窗口大小变化
const handleResize = () => {
if (props.fitContainer) {
nextTick(() => {
fitToContainer();
});
}
};

watch(
[() => playerConfig.value.el, () => playerConfig.value.url],
([playEl, playUrl]) => {
if (playEl && playUrl) {
resetReplay();
initPlayer();
}
},
{
flush: 'post',
immediate: true,
},
);

// 监听自适应配置变化
watch([() => props.fitContainer, () => props.keepAspectRatio, () => props.objectFit], () => {
if (player.value && props.fitContainer) {
nextTick(() => {
fitToContainer();
});
}
});

const resetReplay = () => {
if (player.value) {
player.value.src = '';
player.value.destroy();
player.value = undefined;
}
};

onMounted(() => {
// 添加窗口大小变化监听
if (props.fitContainer) {
window.addEventListener('resize', handleResize);
}
});

onBeforeUnmount(() => {
// 移除窗口大小变化监听
window.removeEventListener('resize', handleResize);
resetReplay();
});

const onPlay = () => {
player.value?.play();
};

const onPause = () => {
player.value?.pause();
};

const togglePlay = () => {
if (player.value?.paused) {
onPlay();
} else {
onPause();
}
};

// 暴露自适应方法
const refitToContainer = () => {
fitToContainer();
};

defineExpose<CustomPlayerExpose>({
onPlay,
onPause,
togglePlay,
player,
refitToContainer, // 暴露手动调整方法
});
</script>

<style scoped>
.custom-player {
display: block;
}

.custom-player :deep(video) {
display: block;
max-width: 100%;
max-height: 100%;
}
</style>

水印插件 WatermarkPlugin​

ts
export interface WatermarkConfig {
text?: string
mode?: 'single' | 'tile' // tile = 铺满模式
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
opacity?: number
fontSize?: string
color?: string
fontFamily?: string
zIndex?: number
rotation?: number // 旋转角度
spacing?: number // 铺满模式的间距
offset?: {
x?: number
y?: number
}
}

interface XGPlayer {
root: HTMLElement
config: any
on: (event: string, callback: Function) => void
off: (event: string, callback: Function) => void
destroy: () => void
}

class WatermarkPlugin {
static pluginName = 'watermark'

private player: XGPlayer
private watermarkContainer: HTMLDivElement | null = null
private config: WatermarkConfig

constructor(player: XGPlayer) {
// 先初始化必需的属性,避免 TypeScript 错误
this.player = player || {} as XGPlayer
this.config = {} as WatermarkConfig

if (!player) {
console.error('❌ 水印插件初始化失败:播放器实例为空')
return
}

// xgplayer 将插件配置直接作为 player.config 传递
const watermarkConfig = player?.config || {}

this.config = this.mergeConfig(watermarkConfig)

// 延迟初始化,确保播放器容器准备就绪
setTimeout(() => {
this.init()
}, 200) // 稍微增加延迟时间
}

private mergeConfig(userConfig: WatermarkConfig): WatermarkConfig {
const defaultConfig: WatermarkConfig = {
text: '© FlexTV',
mode: 'tile', // 默认铺满模式
position: 'bottom-right',
opacity: 0.1, // 提高默认透明度
fontSize: '14px',
color: '#fff',
fontFamily: 'Ubuntu-Regular, sans-serif',
zIndex: 99,
rotation: -25, // 默认倾斜
spacing: 150, // 铺满模式间距
offset: {
x: 16,
y: 20
}
}
const mergedConfig = { ...defaultConfig, ...userConfig }
return mergedConfig
}

private init() {
// 确保播放器实例有效
if (!this.player || !this.player.root) {
console.error('❌ 播放器实例无效,无法初始化水印')
return
}

this.createContainer()
this.render()
this.bindEvents()
}

private createContainer() {
if (this.watermarkContainer) return

this.watermarkContainer = document.createElement('div')
this.watermarkContainer.className = 'xgplayer-watermark'
this.watermarkContainer.style.cssText = `
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
pointer-events: none !important;
z-index: ${this.config.zIndex || 99} !important;
overflow: hidden !important;
background: transparent !important;
`

this.player.root.appendChild(this.watermarkContainer)
}

private render() {
if (!this.config.text || !this.watermarkContainer) {
return
}

// 清空现有内容
this.watermarkContainer.innerHTML = ''

if (this.config.mode === 'tile') {
this.renderTileWatermarks()
} else {
this.renderSingleWatermark()
}
}

private renderSingleWatermark() {
const watermark = this.createWatermarkElement()
this.setSingleWatermarkPosition(watermark)
this.watermarkContainer!.appendChild(watermark)
}

private renderTileWatermarks() {
// 获取播放器实际尺寸
const playerRoot = this.player.root
const width = playerRoot.offsetWidth || playerRoot.clientWidth || 375
const height = playerRoot.offsetHeight || playerRoot.clientHeight || 667

const spacing = this.config.spacing || 150

// 计算需要多少行多少列
const cols = Math.ceil(width / spacing) + 1
const rows = Math.ceil(height / spacing) + 1

let count = 0
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const watermark = this.createWatermarkElement()

const x = col * spacing - spacing / 2 // 偏移半个间距,让水印更好覆盖
const y = row * spacing - spacing / 2

watermark.style.position = 'absolute'
watermark.style.left = `${x}px`
watermark.style.top = `${y}px`
watermark.style.transformOrigin = 'center'

this.watermarkContainer!.appendChild(watermark)
count++
}
}
}

private createWatermarkElement(): HTMLDivElement {
const watermark = document.createElement('div')
watermark.className = 'xgplayer-watermark-item'
watermark.innerText = this.config.text!

const styles = {
opacity: (this.config.opacity || 0.3).toString(),
color: this.config.color || '#fff',
fontSize: this.config.fontSize || '14px',
fontFamily: this.config.fontFamily || 'Ubuntu-Regular, sans-serif',
pointerEvents: 'none',
userSelect: 'none',
whiteSpace: 'nowrap',
transform: `rotate(${this.config.rotation || 0}deg)`,
textShadow: '1px 1px 2px rgba(0, 0, 0, 0.5)',
display: 'block'
}

Object.assign(watermark.style, styles)
return watermark
}

private setSingleWatermarkPosition(watermark: HTMLDivElement) {
const { position = 'bottom-right', offset = { x: 16, y: 20 } } = this.config
const { x = 16, y = 20 } = offset

watermark.style.position = 'absolute'

switch (position) {
case 'top-left':
watermark.style.top = `${y}px`
watermark.style.left = `${x}px`
break
case 'top-right':
watermark.style.top = `${y}px`
watermark.style.right = `${x}px`
break
case 'bottom-left':
watermark.style.bottom = `${y}px`
watermark.style.left = `${x}px`
break
case 'bottom-right':
default:
watermark.style.bottom = `${y}px`
watermark.style.right = `${x}px`
break
}
}

private bindEvents() {
// 安全地绑定事件
if (this.player && typeof this.player.on === 'function') {
try {
this.player.on('destroy', this.destroy.bind(this))
} catch (error) {
}
} else {
console.warn('⚠️ 播放器不支持事件绑定')
}
}

// 公共方法:更新文本
public updateText(text: string) {
this.config.text = text
this.render()
}

// 公共方法:切换模式
public updateMode(mode: 'single' | 'tile') {
this.config.mode = mode
this.render()
}

// 公共方法:更新间距(仅铺满模式有效)
public updateSpacing(spacing: number) {
this.config.spacing = spacing
if (this.config.mode === 'tile') {
this.render()
}
}

// 公共方法:更新透明度
public updateOpacity(opacity: number) {
this.config.opacity = opacity
this.render()
}

// 公共方法:显示/隐藏
public toggle(visible: boolean) {
if (this.watermarkContainer) {
this.watermarkContainer.style.display = visible ? 'block' : 'none'
}
}

// 公共方法:强制重新渲染
public forceRender() {
this.render()
}

// 销毁方法
public destroy() {
if (this.watermarkContainer) {
this.watermarkContainer.remove()
this.watermarkContainer = null
}

// 安全地解绑事件
if (this.player && typeof this.player.off === 'function') {
try {
this.player.off('destroy', this.destroy.bind(this))
} catch (error) {
console.warn('⚠️ 事件解绑失败:', error)
}
}
}
}

export default WatermarkPlugin