跳到主要内容

西瓜视频

西瓜视频封装​

基于西瓜视频xgplayer封装,西瓜视频集成了很多功能

  • custom-player/hooks/useCustomPlayer
ts
import { computed, ref, useAttrs } from 'vue';
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;
};

export enum AspectRatioEnum {
RATIO_16_9 = '16:9',
RATIO_4_3 = '4:3',
RATIO_1_1 = '1:1',
}

export const aspectRatioConfig = {
[AspectRatioEnum.RATIO_16_9]: 9 / 16,
[AspectRatioEnum.RATIO_4_3]: 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(() => {
return {
...props,
...attrs,
el: containerRef.value,
} as IPlayerOptions;
});

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

defineOptions({
inheritAttrs: false,
});

const props = withDefaults(defineProps<CustomPlayerProps>(), {
lang: 'zh',
autoplay: true,
width: 400,
height: 400,
loop: true,
cssFullscreen: false, // 是否展示全屏按钮
});

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

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

const initPlayer = () => {
player.value = new Player(playerConfig.value);
// 播放器创建完成
player.value.once(Events.COMPLETE, () => {
if (containerRef.value) {
// 保持一个特定的宽高比
if (props.aspectRatio && Reflect.has(aspectRatioConfig, props.aspectRatio)) {
const width =
containerRef.value.offsetWidth * aspectRatioConfig[props.aspectRatio] + 'px';
console.log('width', width);
containerRef.value.style.width = width;
}
}

if (props.autoplay) {
player.value?.replay();
}
});
// 播放
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);
}
};

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

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

onBeforeUnmount(() => {
resetReplay();
});

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

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

defineExpose({
onPlay,
onPause,
player,
});
</script>

设置西瓜视频的音量位置​

  • 通过volume的配置,可以对音量的位置进行调整
js
<custom-player
url="xxx"
poster="xxx"
width={200}
height={350}
volume={{ position: 'root', style: { position: 'absolute', right: 0 }
}}

处理m3u8格式的视频​

  • 使用xgplayer-hls插件
    • HLS的全称是HTTP Live Streaming,即HTTP实时流媒体传输。它是一种流媒体传输协议,允许视频和音频内容通过HTTP协议进行传输
    • xgplayer-hls可以处理m3u8格式的文件,m3u8是流媒体传输协议

视频加密处理​

  • 对m3u8的ts片段进行加密
  • 使用xgplayer-hls插件进行加密
    • 支持AES-128 CBC 加密

xgplayer-hls用到了浏览器的cryptoAPI,这个api使用是有限制的,仅在域名为localhost或者https的域名下才能生效

xgplayer-hls插件配置

  • 通过 hls 参数对象传递给 HLS 插件。

支持AES-128 CBC加密"是指:

  • 播放器能够解析和处理AES-128 CBC加密的m3u8文件

  • 播放器内置了解密算法

  • 但仍然需要提供密钥信息

水印插件​

  • 西瓜视频的增加水印,需要使用自定义插件机制

WatermarkPlugin​

js
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: (...args: any[]) => void) => void
off: (event: string, callback: (...args: any[]) => void) => 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: '© 水印',
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.textContent = 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) {
console.log('error', 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

使用​

js
const watermarkConfig: WatermarkConfig = {
text: `水印`,
mode: 'tile',
opacity: 0.3,
fontSize: '12px',
color: '#fff',
rotation: -25,
spacing: 150,
...props.watermarkConfig,
}

plugins.push(WatermarkPlugin)

自定义loading​

  • 在视频加载缓慢、缓冲时,替换掉默认的转圈圈动画,换成自定义的loading效果
  • xgplayer 提供了插件机制,可以通过 自定义插件(Custom Plugin) 来实现 loading 效果替换

方案一 (直接通过css):​

  • xgplayer 内置了一个 xg-loading DOM,你可以直接覆盖样式:
css
.xgplayer .xgplayer-loading {
background: url('/your-custom-loading.gif') no-repeat center center;
background-size: 50px 50px;
}

.xgplayer .xgplayer-loading svg {
display: none; /* 隐藏默认的 svg loading 动画 */
}

方案二 (插件自定义)​

  • 插件监听播放器的 waiting、playing、canplay 事件,来自行控制 loading 显示与隐藏
js
import Player, { Events } from 'xgplayer'

class CustomLoading {
constructor(player) {
this.player = player
this.init()
}

init() {
this.loadingEl = document.createElement('div')
this.loadingEl.className = 'custom-loading'
this.loadingEl.innerHTML = `
<div class="loader">
<img src="/your-loading.gif" alt="loading" />
<p>视频缓冲中,请稍候...</p>
</div>
`
this.loadingEl.style.display = 'none'
this.player.root.appendChild(this.loadingEl)

this.player.on(Events.WAITING, () => {
this.show()
})

this.player.on(Events.PLAYING, () => {
this.hide()
})

this.player.on(Events.CANPLAY, () => {
this.hide()
})
}

show() {
this.loadingEl.style.display = 'flex'
}

hide() {
this.loadingEl.style.display = 'none'
}
}

const player = new Player({
id: 'mse',
url: 'xxx.m3u8',
plugins: [CustomLoading] // 挂载插件
})

  • loading-css
text
.custom-loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: rgba(0,0,0,0.3);
z-index: 10;
color: #fff;
}