
12 changed files with 108293 additions and 15 deletions
-
1demo/components.d.ts
-
1demo/package.json
-
20demo/src/App.vue
-
34demo/src/vite-env.d.ts
-
316packages/hls/bundled-demo.html
-
35899packages/hls/dist/hls-video.js
-
35340packages/hls/dist/hls.js
-
35606packages/hls/dist/hls.mjs
-
10packages/hls/package.json
-
559packages/hls/src/hls-video.js
-
517packages/hls/src/hls-video.mjs
-
5pnpm-lock.yaml
@ -1,7 +1,35 @@ |
|||
/// <reference types="vite/client" />
|
|||
|
|||
declare module '*.vue' { |
|||
import type { DefineComponent } from 'vue' |
|||
const component: DefineComponent<{}, {}, any> |
|||
export default component |
|||
import type { DefineComponent } from 'vue'; |
|||
const component: DefineComponent<{}, {}, any>; |
|||
export default component; |
|||
} |
|||
|
|||
// 声明 HLS Video Web Component
|
|||
declare global { |
|||
namespace JSX { |
|||
interface IntrinsicElements { |
|||
'hls-video': { |
|||
src?: string; |
|||
controls?: boolean; |
|||
autoplay?: boolean; |
|||
muted?: boolean; |
|||
loop?: boolean; |
|||
poster?: string; |
|||
width?: string | number; |
|||
height?: string | number; |
|||
preload?: string; |
|||
crossorigin?: string; |
|||
playsinline?: boolean; |
|||
volume?: string | number; |
|||
'playback-rate'?: string | number; |
|||
'current-time'?: string | number; |
|||
'quality-level'?: string | number; |
|||
debug?: boolean; |
|||
'low-latency'?: boolean; |
|||
'max-buffer-length'?: string | number; |
|||
}; |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,316 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="zh-CN"> |
|||
|
|||
<head> |
|||
<meta charset="UTF-8"> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|||
<title>打包版 HLS Video Web Components 演示</title> |
|||
<style> |
|||
body { |
|||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
|||
max-width: 1200px; |
|||
margin: 0 auto; |
|||
padding: 20px; |
|||
line-height: 1.6; |
|||
} |
|||
|
|||
.demo-section { |
|||
margin-bottom: 40px; |
|||
padding: 20px; |
|||
border: 1px solid #e1e5e9; |
|||
border-radius: 8px; |
|||
} |
|||
|
|||
.demo-section h2 { |
|||
margin-top: 0; |
|||
color: #2c3e50; |
|||
} |
|||
|
|||
.video-container { |
|||
margin: 20px 0; |
|||
} |
|||
|
|||
hls-video, |
|||
simple-hls-video { |
|||
width: 100%; |
|||
max-width: 800px; |
|||
height: auto; |
|||
border-radius: 8px; |
|||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); |
|||
} |
|||
|
|||
.controls { |
|||
margin-top: 15px; |
|||
display: flex; |
|||
gap: 10px; |
|||
flex-wrap: wrap; |
|||
} |
|||
|
|||
button { |
|||
padding: 8px 16px; |
|||
border: 1px solid #ddd; |
|||
border-radius: 4px; |
|||
background: #f8f9fa; |
|||
cursor: pointer; |
|||
font-size: 14px; |
|||
} |
|||
|
|||
button:hover { |
|||
background: #e9ecef; |
|||
} |
|||
|
|||
.info-box { |
|||
background: #e3f2fd; |
|||
border-left: 4px solid #2196f3; |
|||
padding: 15px; |
|||
margin: 20px 0; |
|||
} |
|||
|
|||
.code-block { |
|||
background: #f6f8fa; |
|||
border: 1px solid #e1e4e8; |
|||
border-radius: 6px; |
|||
padding: 16px; |
|||
margin: 16px 0; |
|||
overflow-x: auto; |
|||
} |
|||
|
|||
.code-block code { |
|||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; |
|||
font-size: 14px; |
|||
} |
|||
</style> |
|||
</head> |
|||
|
|||
<body> |
|||
<h1>打包版 HLS Video Web Components 演示</h1> |
|||
|
|||
<div class="info-box"> |
|||
<strong>说明:</strong>这个页面展示了打包了 HLS.js 的 Web Components,无需额外加载任何外部库。 |
|||
Web Components 已经包含了所有必要的功能。 |
|||
</div> |
|||
|
|||
<!-- 简化版演示 --> |
|||
<div class="demo-section"> |
|||
<h2>1. 简化版 Web Component</h2> |
|||
<p>文件大小小,功能专注于基本播放需求</p> |
|||
|
|||
<div class="code-block"> |
|||
<code><script src="dist/simple-hls-video.min.js"></script> |
|||
<simple-hls-video src="video.m3u8" controls autoplay muted></simple-hls-video></code> |
|||
</div> |
|||
|
|||
<div class="video-container"> |
|||
<simple-hls-video id="simple-player" src="https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8" controls autoplay |
|||
muted width="800" height="450"> |
|||
</simple-hls-video> |
|||
</div> |
|||
|
|||
<div class="controls"> |
|||
<button onclick="simplePlayer.play()">播放</button> |
|||
<button onclick="simplePlayer.pause()">暂停</button> |
|||
<button onclick="simplePlayer.currentTime = 0">重播</button> |
|||
<button onclick="simplePlayer.currentTime += 10">快进10秒</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 完整版演示 --> |
|||
<div class="demo-section"> |
|||
<h2>2. 完整版 Web Component</h2> |
|||
<p>功能丰富,包含画质控制和高级功能</p> |
|||
|
|||
<div class="code-block"> |
|||
<code><script src="dist/hls-video.min.js"></script> |
|||
<hls-video src="video.m3u8" controls debug></hls-video></code> |
|||
</div> |
|||
|
|||
<div class="video-container"> |
|||
<hls-video id="full-player" src="https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8" controls debug width="800" |
|||
height="450"> |
|||
</hls-video> |
|||
</div> |
|||
|
|||
<div class="controls"> |
|||
<button onclick="fullPlayer.play()">播放</button> |
|||
<button onclick="fullPlayer.pause()">暂停</button> |
|||
<button onclick="setQuality(-1)">自动画质</button> |
|||
<button onclick="setQuality(0)">最低画质</button> |
|||
<button onclick="showPlayerInfo()">显示信息</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- ES Module 演示 --> |
|||
<div class="demo-section"> |
|||
<h2>3. ES Module 使用方式</h2> |
|||
<p>在支持 ES 模块的现代环境中使用</p> |
|||
|
|||
<div class="code-block"> |
|||
<code>// 导入简化版 |
|||
import 'dist/simple-hls-video.esm.js'; |
|||
|
|||
// 或导入完整版 |
|||
import 'dist/hls-video.esm.js'; |
|||
|
|||
// 在 HTML 中使用 |
|||
<simple-hls-video src="video.m3u8" controls></simple-hls-video></code> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 框架集成 --> |
|||
<div class="demo-section"> |
|||
<h2>4. 框架集成示例</h2> |
|||
|
|||
<h3>Vue 3</h3> |
|||
<div class="code-block"> |
|||
<code>// main.js |
|||
import 'dist/simple-hls-video.esm.js'; |
|||
|
|||
// Component.vue |
|||
<template> |
|||
<simple-hls-video |
|||
:src="videoUrl" |
|||
controls |
|||
@play="onPlay" |
|||
@pause="onPause" |
|||
ref="player" |
|||
></simple-hls-video> |
|||
</template> |
|||
|
|||
<script setup> |
|||
import { ref } from 'vue'; |
|||
|
|||
const player = ref(null); |
|||
const videoUrl = ref('video.m3u8'); |
|||
|
|||
const onPlay = () => console.log('播放开始'); |
|||
const onPause = () => console.log('播放暂停'); |
|||
</script></code> |
|||
</div> |
|||
|
|||
<h3>React</h3> |
|||
<div class="code-block"> |
|||
<code>// App.js |
|||
import React, { useRef, useEffect } from 'react'; |
|||
import 'dist/simple-hls-video.esm.js'; |
|||
|
|||
function VideoPlayer() { |
|||
const playerRef = useRef(null); |
|||
|
|||
useEffect(() => { |
|||
const player = playerRef.current; |
|||
|
|||
const handlePlay = () => console.log('播放开始'); |
|||
const handlePause = () => console.log('播放暂停'); |
|||
|
|||
player.addEventListener('play', handlePlay); |
|||
player.addEventListener('pause', handlePause); |
|||
|
|||
return () => { |
|||
player.removeEventListener('play', handlePlay); |
|||
player.removeEventListener('pause', handlePause); |
|||
}; |
|||
}, []); |
|||
|
|||
return ( |
|||
<simple-hls-video |
|||
ref={playerRef} |
|||
src="video.m3u8" |
|||
controls |
|||
/> |
|||
); |
|||
}</code> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 构建说明 --> |
|||
<div class="demo-section"> |
|||
<h2>5. 构建说明</h2> |
|||
|
|||
<h3>构建命令</h3> |
|||
<div class="code-block"> |
|||
<code># 构建 HLS.js 库 |
|||
npm run build |
|||
|
|||
# 构建 Web Components |
|||
npm run build:webcomponents |
|||
|
|||
# 构建所有 |
|||
npm run build:all |
|||
|
|||
# 开发模式(监听变化) |
|||
npm run dev:webcomponents</code> |
|||
</div> |
|||
|
|||
<h3>输出文件</h3> |
|||
<ul> |
|||
<li><code>web-components/dist/simple-hls-video.js</code> - 简化版 IIFE 格式</li> |
|||
<li><code>web-components/dist/simple-hls-video.min.js</code> - 简化版压缩版</li> |
|||
<li><code>web-components/dist/simple-hls-video.esm.js</code> - 简化版 ES 模块</li> |
|||
<li><code>web-components/dist/hls-video.js</code> - 完整版 IIFE 格式</li> |
|||
<li><code>web-components/dist/hls-video.min.js</code> - 完整版压缩版</li> |
|||
<li><code>web-components/dist/hls-video.esm.js</code> - 完整版 ES 模块</li> |
|||
</ul> |
|||
|
|||
<h3>文件大小对比</h3> |
|||
<ul> |
|||
<li>简化版:~200KB (压缩后)</li> |
|||
<li>完整版:~300KB (压缩后)</li> |
|||
<li>原始 HLS.js:~250KB</li> |
|||
</ul> |
|||
</div> |
|||
|
|||
<script src="dist/hls-video.js"></script> |
|||
|
|||
<script> |
|||
// 获取播放器引用 |
|||
const simplePlayer = document.getElementById('simple-player'); |
|||
const fullPlayer = document.getElementById('full-player'); |
|||
|
|||
// 设置画质 |
|||
function setQuality(level) { |
|||
if (fullPlayer && fullPlayer.currentLevel !== undefined) { |
|||
fullPlayer.currentLevel = level; |
|||
} |
|||
} |
|||
|
|||
// 显示播放器信息 |
|||
function showPlayerInfo() { |
|||
if (!fullPlayer) return; |
|||
|
|||
const info = [ |
|||
`当前时间: ${fullPlayer.currentTime?.toFixed(2) || 0}s`, |
|||
`总时长: ${fullPlayer.duration?.toFixed(2) || 0}s`, |
|||
`音量: ${(fullPlayer.volume * 100).toFixed(0)}%`, |
|||
`当前画质: ${fullPlayer.currentLevel || 'N/A'}`, |
|||
`可用画质: ${fullPlayer.levels?.length || 0}个`, |
|||
`是否暂停: ${fullPlayer.paused}` |
|||
]; |
|||
|
|||
alert(info.join('\n')); |
|||
} |
|||
|
|||
// 监听事件 |
|||
simplePlayer.addEventListener('play', () => { |
|||
console.log('简化版播放器: 开始播放'); |
|||
}); |
|||
|
|||
simplePlayer.addEventListener('pause', () => { |
|||
console.log('简化版播放器: 暂停播放'); |
|||
}); |
|||
|
|||
fullPlayer.addEventListener('hlsmanifestparsed', (event) => { |
|||
console.log('完整版播放器: 清单解析完成', event.detail.levels.length, '个画质级别'); |
|||
}); |
|||
|
|||
fullPlayer.addEventListener('hlslevelswitched', (event) => { |
|||
console.log('完整版播放器: 画质切换到', event.detail.level); |
|||
}); |
|||
|
|||
// 页面加载完成提示 |
|||
document.addEventListener('DOMContentLoaded', () => { |
|||
console.log('打包版 Web Components 加载完成!'); |
|||
}); |
|||
</script> |
|||
</body> |
|||
|
|||
</html> |
35899
packages/hls/dist/hls-video.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
35340
packages/hls/dist/hls.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
35606
packages/hls/dist/hls.mjs
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,10 @@ |
|||
{ |
|||
"name": "jv4-hls", |
|||
"version": "1.0.0", |
|||
"description": "", |
|||
"type": "module", |
|||
"module": "src/hls-video.mjs", |
|||
"keywords": [], |
|||
"author": "dexter", |
|||
"license": "ISC" |
|||
} |
@ -0,0 +1,559 @@ |
|||
/** |
|||
* HLS Video Web Component |
|||
* 一个封装了 HLS.js 的自定义元素,提供类似 Safari 原生 HLS 播放的简单用法 |
|||
* |
|||
* 使用方法: |
|||
* <hls-video src="path/to/playlist.m3u8" controls autoplay muted></hls-video> |
|||
*/ |
|||
|
|||
class HlsVideo extends HTMLElement { |
|||
constructor() { |
|||
super(); |
|||
|
|||
// 创建 Shadow DOM
|
|||
this.attachShadow({ mode: 'open' }); |
|||
|
|||
// 初始化状态
|
|||
this.hls = null; |
|||
this.video = null; |
|||
this.isHlsSupported = false; |
|||
this.isNativeHlsSupported = false; |
|||
|
|||
// 绑定方法
|
|||
this.handleError = this.handleError.bind(this); |
|||
this.handleLoadStart = this.handleLoadStart.bind(this); |
|||
this.handleCanPlay = this.handleCanPlay.bind(this); |
|||
|
|||
// 创建样式和模板
|
|||
this.createTemplate(); |
|||
|
|||
// 检查 HLS 支持
|
|||
this.checkHlsSupport(); |
|||
} |
|||
|
|||
static get observedAttributes() { |
|||
return [ |
|||
'src', 'controls', 'autoplay', 'muted', 'loop', 'poster', |
|||
'width', 'height', 'preload', 'crossorigin', 'playsinline', |
|||
'volume', 'playback-rate', 'current-time', 'quality-level', |
|||
'debug', 'low-latency', 'max-buffer-length' |
|||
]; |
|||
} |
|||
|
|||
createTemplate() { |
|||
const template = document.createElement('template'); |
|||
template.innerHTML = `
|
|||
<style> |
|||
:host { |
|||
display: inline-block; |
|||
position: relative; |
|||
max-width: 100%; |
|||
} |
|||
|
|||
video { |
|||
width: 100%; |
|||
height: 100%; |
|||
display: block; |
|||
} |
|||
|
|||
.error-message { |
|||
position: absolute; |
|||
top: 50%; |
|||
left: 50%; |
|||
transform: translate(-50%, -50%); |
|||
background: rgba(220, 53, 69, 0.9); |
|||
color: white; |
|||
padding: 12px 20px; |
|||
border-radius: 4px; |
|||
font-family: Arial, sans-serif; |
|||
font-size: 14px; |
|||
text-align: center; |
|||
max-width: 80%; |
|||
z-index: 10; |
|||
} |
|||
|
|||
.loading { |
|||
position: absolute; |
|||
top: 50%; |
|||
left: 50%; |
|||
transform: translate(-50%, -50%); |
|||
background: rgba(0, 0, 0, 0.7); |
|||
color: white; |
|||
padding: 12px 20px; |
|||
border-radius: 4px; |
|||
font-family: Arial, sans-serif; |
|||
font-size: 14px; |
|||
z-index: 5; |
|||
} |
|||
|
|||
.quality-selector { |
|||
position: absolute; |
|||
bottom: 10px; |
|||
right: 10px; |
|||
background: rgba(0, 0, 0, 0.7); |
|||
border-radius: 4px; |
|||
padding: 5px; |
|||
z-index: 15; |
|||
} |
|||
|
|||
.quality-selector select { |
|||
background: transparent; |
|||
color: white; |
|||
border: none; |
|||
padding: 2px 5px; |
|||
font-size: 12px; |
|||
} |
|||
|
|||
.quality-selector select option { |
|||
background: black; |
|||
color: white; |
|||
} |
|||
|
|||
:host([hidden]) { |
|||
display: none !important; |
|||
} |
|||
</style> |
|||
|
|||
<video part="video"></video> |
|||
<div class="loading" part="loading" style="display: none;">加载中...</div> |
|||
<div class="error-message" part="error" style="display: none;"></div> |
|||
<div class="quality-selector" part="quality-selector" style="display: none;"> |
|||
<select part="quality-select"> |
|||
<option value="-1">自动</option> |
|||
</select> |
|||
</div> |
|||
`;
|
|||
|
|||
this.shadowRoot.appendChild(template.content.cloneNode(true)); |
|||
|
|||
// 获取模板元素引用
|
|||
this.video = this.shadowRoot.querySelector('video'); |
|||
this.loadingElement = this.shadowRoot.querySelector('.loading'); |
|||
this.errorElement = this.shadowRoot.querySelector('.error-message'); |
|||
this.qualitySelector = this.shadowRoot.querySelector('.quality-selector'); |
|||
this.qualitySelect = this.shadowRoot.querySelector('select'); |
|||
|
|||
// 绑定事件
|
|||
this.setupVideoEvents(); |
|||
this.setupQualitySelector(); |
|||
} |
|||
|
|||
async checkHlsSupport() { |
|||
// 检查 HLS.js 支持
|
|||
if (typeof window.Hls !== 'undefined') { |
|||
this.isHlsSupported = window.Hls.isSupported(); |
|||
} else { |
|||
// 动态加载 HLS.js
|
|||
try { |
|||
await this.loadHlsJs(); |
|||
this.isHlsSupported = window.Hls.isSupported(); |
|||
} catch (error) { |
|||
console.warn('Failed to load HLS.js:', error); |
|||
} |
|||
} |
|||
|
|||
// 检查原生 HLS 支持
|
|||
this.isNativeHlsSupported = this.video.canPlayType('application/vnd.apple.mpegurl') !== ''; |
|||
} |
|||
|
|||
async loadHlsJs() { |
|||
return new Promise((resolve, reject) => { |
|||
if (typeof window.Hls !== 'undefined') { |
|||
resolve(); |
|||
return; |
|||
} |
|||
|
|||
const script = document.createElement('script'); |
|||
script.src = 'https://cdn.jsdelivr.net/npm/hls.js@latest'; |
|||
script.onload = resolve; |
|||
script.onerror = reject; |
|||
document.head.appendChild(script); |
|||
}); |
|||
} |
|||
|
|||
setupVideoEvents() { |
|||
this.video.addEventListener('loadstart', this.handleLoadStart); |
|||
this.video.addEventListener('canplay', this.handleCanPlay); |
|||
this.video.addEventListener('error', this.handleError); |
|||
|
|||
// 代理常用的视频事件
|
|||
const eventsToProxy = [ |
|||
'loadstart', 'loadeddata', 'loadedmetadata', 'canplay', 'canplaythrough', |
|||
'play', 'pause', 'playing', 'waiting', 'seeking', 'seeked', 'ended', |
|||
'timeupdate', 'progress', 'durationchange', 'volumechange', 'ratechange', |
|||
'resize', 'enterpictureinpicture', 'leavepictureinpicture' |
|||
]; |
|||
|
|||
eventsToProxy.forEach(eventName => { |
|||
this.video.addEventListener(eventName, (event) => { |
|||
this.dispatchEvent(new CustomEvent(eventName, { |
|||
detail: event, |
|||
bubbles: false |
|||
})); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
setupQualitySelector() { |
|||
this.qualitySelect.addEventListener('change', () => { |
|||
const level = parseInt(this.qualitySelect.value); |
|||
this.setQualityLevel(level); |
|||
}); |
|||
} |
|||
|
|||
connectedCallback() { |
|||
// 元素被添加到 DOM 时
|
|||
this.updateVideoAttributes(); |
|||
if (this.getAttribute('src')) { |
|||
this.loadSource(); |
|||
} |
|||
} |
|||
|
|||
disconnectedCallback() { |
|||
// 元素被移除时清理
|
|||
this.destroyHls(); |
|||
} |
|||
|
|||
attributeChangedCallback(name, oldValue, newValue) { |
|||
if (oldValue === newValue) return; |
|||
|
|||
switch (name) { |
|||
case 'src': |
|||
if (newValue) { |
|||
this.loadSource(); |
|||
} |
|||
break; |
|||
case 'quality-level': |
|||
this.setQualityLevel(parseInt(newValue) || -1); |
|||
break; |
|||
default: |
|||
this.updateVideoAttributes(); |
|||
break; |
|||
} |
|||
} |
|||
|
|||
updateVideoAttributes() { |
|||
// 同步属性到内部 video 元素
|
|||
const videoAttributes = [ |
|||
'controls', 'autoplay', 'muted', 'loop', 'poster', |
|||
'width', 'height', 'preload', 'crossorigin', 'playsinline' |
|||
]; |
|||
|
|||
videoAttributes.forEach(attr => { |
|||
if (this.hasAttribute(attr)) { |
|||
const value = this.getAttribute(attr); |
|||
if (value === '' || value === attr) { |
|||
this.video.setAttribute(attr, ''); |
|||
} else { |
|||
this.video.setAttribute(attr, value); |
|||
} |
|||
} else { |
|||
this.video.removeAttribute(attr); |
|||
} |
|||
}); |
|||
|
|||
// 处理数值属性
|
|||
const volume = this.getAttribute('volume'); |
|||
if (volume !== null) { |
|||
this.video.volume = parseFloat(volume); |
|||
} |
|||
|
|||
const playbackRate = this.getAttribute('playback-rate'); |
|||
if (playbackRate !== null) { |
|||
this.video.playbackRate = parseFloat(playbackRate); |
|||
} |
|||
|
|||
const currentTime = this.getAttribute('current-time'); |
|||
if (currentTime !== null) { |
|||
this.video.currentTime = parseFloat(currentTime); |
|||
} |
|||
} |
|||
|
|||
async loadSource() { |
|||
const src = this.getAttribute('src'); |
|||
if (!src) return; |
|||
|
|||
this.showLoading(); |
|||
this.hideError(); |
|||
|
|||
try { |
|||
await this.checkHlsSupport(); |
|||
|
|||
if (this.isHlsUrl(src)) { |
|||
if (this.isHlsSupported) { |
|||
await this.loadWithHlsJs(src); |
|||
} else if (this.isNativeHlsSupported) { |
|||
this.loadWithNativeHls(src); |
|||
} else { |
|||
throw new Error('您的浏览器不支持 HLS 播放'); |
|||
} |
|||
} else { |
|||
// 非 HLS 源,直接使用 video 元素
|
|||
this.video.src = src; |
|||
} |
|||
} catch (error) { |
|||
this.showError(error.message); |
|||
} |
|||
} |
|||
|
|||
isHlsUrl(url) { |
|||
return url.includes('.m3u8') || url.includes('application/vnd.apple.mpegurl'); |
|||
} |
|||
|
|||
async loadWithHlsJs(src) { |
|||
this.destroyHls(); |
|||
|
|||
const config = this.getHlsConfig(); |
|||
this.hls = new window.Hls(config); |
|||
|
|||
this.setupHlsEvents(); |
|||
this.hls.loadSource(src); |
|||
this.hls.attachMedia(this.video); |
|||
} |
|||
|
|||
loadWithNativeHls(src) { |
|||
this.video.src = src; |
|||
this.hideLoading(); |
|||
} |
|||
|
|||
getHlsConfig() { |
|||
const debug = this.hasAttribute('debug'); |
|||
const lowLatency = this.hasAttribute('low-latency'); |
|||
const maxBufferLength = parseInt(this.getAttribute('max-buffer-length')) || 30; |
|||
|
|||
return { |
|||
debug, |
|||
lowLatencyMode: lowLatency, |
|||
maxBufferLength, |
|||
enableWorker: true, |
|||
autoStartLoad: true, |
|||
startPosition: -1, |
|||
capLevelToPlayerSize: false |
|||
}; |
|||
} |
|||
|
|||
setupHlsEvents() { |
|||
if (!this.hls) return; |
|||
|
|||
this.hls.on(window.Hls.Events.MEDIA_ATTACHED, () => { |
|||
console.log('HLS: Media attached'); |
|||
}); |
|||
|
|||
this.hls.on(window.Hls.Events.MANIFEST_PARSED, (event, data) => { |
|||
console.log('HLS: Manifest parsed', data.levels.length, 'quality levels'); |
|||
this.hideLoading(); |
|||
this.updateQualitySelector(data.levels); |
|||
|
|||
// 触发自定义事件
|
|||
this.dispatchEvent(new CustomEvent('hlsmanifestparsed', { |
|||
detail: { levels: data.levels } |
|||
})); |
|||
}); |
|||
|
|||
this.hls.on(window.Hls.Events.LEVEL_SWITCHED, (event, data) => { |
|||
console.log('HLS: Level switched to', data.level); |
|||
this.qualitySelect.value = data.level.toString(); |
|||
|
|||
this.dispatchEvent(new CustomEvent('hlslevelswitched', { |
|||
detail: { level: data.level } |
|||
})); |
|||
}); |
|||
|
|||
this.hls.on(window.Hls.Events.ERROR, (event, data) => { |
|||
console.error('HLS Error:', data); |
|||
|
|||
if (data.fatal) { |
|||
switch (data.type) { |
|||
case window.Hls.ErrorTypes.NETWORK_ERROR: |
|||
console.log('尝试恢复网络错误...'); |
|||
this.hls.startLoad(); |
|||
break; |
|||
case window.Hls.ErrorTypes.MEDIA_ERROR: |
|||
console.log('尝试恢复媒体错误...'); |
|||
this.hls.recoverMediaError(); |
|||
break; |
|||
default: |
|||
this.showError(`播放错误: ${data.details}`); |
|||
this.destroyHls(); |
|||
break; |
|||
} |
|||
} |
|||
|
|||
this.dispatchEvent(new CustomEvent('hlserror', { |
|||
detail: data |
|||
})); |
|||
}); |
|||
} |
|||
|
|||
updateQualitySelector(levels) { |
|||
// 清空现有选项
|
|||
this.qualitySelect.innerHTML = '<option value="-1">自动</option>'; |
|||
|
|||
// 添加质量级别选项
|
|||
levels.forEach((level, index) => { |
|||
const option = document.createElement('option'); |
|||
option.value = index.toString(); |
|||
option.textContent = `${level.height}p (${Math.round(level.bitrate / 1000)}k)`; |
|||
this.qualitySelect.appendChild(option); |
|||
}); |
|||
|
|||
// 显示质量选择器(如果有多个级别)
|
|||
if (levels.length > 1) { |
|||
this.qualitySelector.style.display = 'block'; |
|||
} |
|||
} |
|||
|
|||
setQualityLevel(level) { |
|||
if (this.hls) { |
|||
this.hls.currentLevel = level; |
|||
} |
|||
} |
|||
|
|||
destroyHls() { |
|||
if (this.hls) { |
|||
this.hls.destroy(); |
|||
this.hls = null; |
|||
} |
|||
} |
|||
|
|||
showLoading() { |
|||
this.loadingElement.style.display = 'block'; |
|||
} |
|||
|
|||
hideLoading() { |
|||
this.loadingElement.style.display = 'none'; |
|||
} |
|||
|
|||
showError(message) { |
|||
this.errorElement.textContent = message; |
|||
this.errorElement.style.display = 'block'; |
|||
this.hideLoading(); |
|||
} |
|||
|
|||
hideError() { |
|||
this.errorElement.style.display = 'none'; |
|||
} |
|||
|
|||
handleLoadStart() { |
|||
this.hideError(); |
|||
} |
|||
|
|||
handleCanPlay() { |
|||
this.hideLoading(); |
|||
} |
|||
|
|||
handleError(event) { |
|||
const error = this.video.error; |
|||
if (error) { |
|||
let message = '视频播放出错'; |
|||
switch (error.code) { |
|||
case error.MEDIA_ERR_ABORTED: |
|||
message = '视频播放被中止'; |
|||
break; |
|||
case error.MEDIA_ERR_NETWORK: |
|||
message = '网络错误导致视频下载失败'; |
|||
break; |
|||
case error.MEDIA_ERR_DECODE: |
|||
message = '视频解码失败'; |
|||
break; |
|||
case error.MEDIA_ERR_SRC_NOT_SUPPORTED: |
|||
message = '视频格式不支持'; |
|||
break; |
|||
} |
|||
this.showError(message); |
|||
} |
|||
} |
|||
|
|||
// 公开的 API 方法
|
|||
play() { |
|||
return this.video.play(); |
|||
} |
|||
|
|||
pause() { |
|||
this.video.pause(); |
|||
} |
|||
|
|||
load() { |
|||
this.video.load(); |
|||
} |
|||
|
|||
// 获取当前质量级别
|
|||
get currentLevel() { |
|||
return this.hls ? this.hls.currentLevel : -1; |
|||
} |
|||
|
|||
// 设置质量级别
|
|||
set currentLevel(level) { |
|||
this.setQualityLevel(level); |
|||
} |
|||
|
|||
// 获取可用的质量级别
|
|||
get levels() { |
|||
return this.hls ? this.hls.levels : []; |
|||
} |
|||
|
|||
// 获取/设置音量
|
|||
get volume() { |
|||
return this.video.volume; |
|||
} |
|||
|
|||
set volume(value) { |
|||
this.video.volume = value; |
|||
} |
|||
|
|||
// 获取/设置当前时间
|
|||
get currentTime() { |
|||
return this.video.currentTime; |
|||
} |
|||
|
|||
set currentTime(value) { |
|||
this.video.currentTime = value; |
|||
} |
|||
|
|||
// 获取持续时间
|
|||
get duration() { |
|||
return this.video.duration; |
|||
} |
|||
|
|||
// 获取/设置播放速率
|
|||
get playbackRate() { |
|||
return this.video.playbackRate; |
|||
} |
|||
|
|||
set playbackRate(value) { |
|||
this.video.playbackRate = value; |
|||
} |
|||
|
|||
// 获取是否暂停
|
|||
get paused() { |
|||
return this.video.paused; |
|||
} |
|||
|
|||
// 获取是否结束
|
|||
get ended() { |
|||
return this.video.ended; |
|||
} |
|||
|
|||
// 获取缓冲范围
|
|||
get buffered() { |
|||
return this.video.buffered; |
|||
} |
|||
|
|||
// 获取网络状态
|
|||
get networkState() { |
|||
return this.video.networkState; |
|||
} |
|||
|
|||
// 获取就绪状态
|
|||
get readyState() { |
|||
return this.video.readyState; |
|||
} |
|||
} |
|||
|
|||
// 注册自定义元素
|
|||
customElements.define('hls-video', HlsVideo); |
|||
|
|||
// 如果在模块环境中,导出类
|
|||
if (typeof module !== 'undefined' && module.exports) { |
|||
module.exports = HlsVideo; |
|||
} |
@ -0,0 +1,517 @@ |
|||
/** |
|||
* HLS Video Web Component - Full Version with bundled HLS.js |
|||
* 完整功能版本,直接导入本地 HLS.js |
|||
*/ |
|||
|
|||
// 导入本地的 HLS.js (UMD 格式) |
|||
import Hls from '../dist/hls.mjs'; |
|||
|
|||
class HlsVideo extends HTMLElement { |
|||
constructor() { |
|||
super(); |
|||
|
|||
// 创建 Shadow DOM |
|||
this.attachShadow({ mode: 'open' }); |
|||
|
|||
// 初始化状态 |
|||
this.hls = null; |
|||
this.video = null; |
|||
|
|||
// 绑定方法 |
|||
this.handleError = this.handleError.bind(this); |
|||
this.handleLoadStart = this.handleLoadStart.bind(this); |
|||
this.handleCanPlay = this.handleCanPlay.bind(this); |
|||
|
|||
// 创建样式和模板 |
|||
this.createTemplate(); |
|||
} |
|||
|
|||
static get observedAttributes() { |
|||
return [ |
|||
'src', 'controls', 'autoplay', 'muted', 'loop', 'poster', |
|||
'width', 'height', 'preload', 'crossorigin', 'playsinline', |
|||
'volume', 'playback-rate', 'current-time', 'quality-level', |
|||
'debug', 'low-latency', 'max-buffer-length' |
|||
]; |
|||
} |
|||
|
|||
createTemplate() { |
|||
const template = document.createElement('template'); |
|||
template.innerHTML = ` |
|||
<style> |
|||
:host { |
|||
display: inline-block; |
|||
position: relative; |
|||
max-width: 100%; |
|||
} |
|||
|
|||
video { |
|||
width: 100%; |
|||
height: 100%; |
|||
display: block; |
|||
} |
|||
|
|||
.error-message { |
|||
position: absolute; |
|||
top: 50%; |
|||
left: 50%; |
|||
transform: translate(-50%, -50%); |
|||
background: rgba(220, 53, 69, 0.9); |
|||
color: white; |
|||
padding: 12px 20px; |
|||
border-radius: 4px; |
|||
font-family: Arial, sans-serif; |
|||
font-size: 14px; |
|||
text-align: center; |
|||
max-width: 80%; |
|||
z-index: 10; |
|||
} |
|||
|
|||
.loading { |
|||
position: absolute; |
|||
top: 50%; |
|||
left: 50%; |
|||
transform: translate(-50%, -50%); |
|||
background: rgba(0, 0, 0, 0.7); |
|||
color: white; |
|||
padding: 12px 20px; |
|||
border-radius: 4px; |
|||
font-family: Arial, sans-serif; |
|||
font-size: 14px; |
|||
z-index: 5; |
|||
} |
|||
|
|||
.quality-selector { |
|||
position: absolute; |
|||
bottom: 10px; |
|||
right: 10px; |
|||
background: rgba(0, 0, 0, 0.7); |
|||
border-radius: 4px; |
|||
padding: 5px; |
|||
z-index: 15; |
|||
} |
|||
|
|||
.quality-selector select { |
|||
background: transparent; |
|||
color: white; |
|||
border: none; |
|||
padding: 2px 5px; |
|||
font-size: 12px; |
|||
} |
|||
|
|||
.quality-selector select option { |
|||
background: black; |
|||
color: white; |
|||
} |
|||
|
|||
:host([hidden]) { |
|||
display: none !important; |
|||
} |
|||
</style> |
|||
|
|||
<video part="video"></video> |
|||
<div class="loading" part="loading" style="display: none;">加载中...</div> |
|||
<div class="error-message" part="error" style="display: none;"></div> |
|||
<div class="quality-selector" part="quality-selector" style="display: none;"> |
|||
<select part="quality-select"> |
|||
<option value="-1">自动</option> |
|||
</select> |
|||
</div> |
|||
`; |
|||
|
|||
this.shadowRoot.appendChild(template.content.cloneNode(true)); |
|||
|
|||
// 获取模板元素引用 |
|||
this.video = this.shadowRoot.querySelector('video'); |
|||
this.loadingElement = this.shadowRoot.querySelector('.loading'); |
|||
this.errorElement = this.shadowRoot.querySelector('.error-message'); |
|||
this.qualitySelector = this.shadowRoot.querySelector('.quality-selector'); |
|||
this.qualitySelect = this.shadowRoot.querySelector('select'); |
|||
|
|||
// 绑定事件 |
|||
this.setupVideoEvents(); |
|||
this.setupQualitySelector(); |
|||
} |
|||
|
|||
setupVideoEvents() { |
|||
this.video.addEventListener('loadstart', this.handleLoadStart); |
|||
this.video.addEventListener('canplay', this.handleCanPlay); |
|||
this.video.addEventListener('error', this.handleError); |
|||
|
|||
// 代理常用的视频事件 |
|||
const eventsToProxy = [ |
|||
'loadstart', 'loadeddata', 'loadedmetadata', 'canplay', 'canplaythrough', |
|||
'play', 'pause', 'playing', 'waiting', 'seeking', 'seeked', 'ended', |
|||
'timeupdate', 'progress', 'durationchange', 'volumechange', 'ratechange', |
|||
'resize', 'enterpictureinpicture', 'leavepictureinpicture' |
|||
]; |
|||
|
|||
eventsToProxy.forEach(eventName => { |
|||
this.video.addEventListener(eventName, (event) => { |
|||
this.dispatchEvent(new CustomEvent(eventName, { |
|||
detail: event, |
|||
bubbles: false |
|||
})); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
setupQualitySelector() { |
|||
this.qualitySelect.addEventListener('change', () => { |
|||
const level = parseInt(this.qualitySelect.value); |
|||
this.setQualityLevel(level); |
|||
}); |
|||
} |
|||
|
|||
connectedCallback() { |
|||
// 元素被添加到 DOM 时 |
|||
this.updateVideoAttributes(); |
|||
if (this.getAttribute('src')) { |
|||
this.loadSource(); |
|||
} |
|||
} |
|||
|
|||
disconnectedCallback() { |
|||
// 元素被移除时清理 |
|||
this.destroyHls(); |
|||
} |
|||
|
|||
attributeChangedCallback(name, oldValue, newValue) { |
|||
if (oldValue === newValue) return; |
|||
|
|||
switch (name) { |
|||
case 'src': |
|||
if (newValue) { |
|||
this.loadSource(); |
|||
} |
|||
break; |
|||
case 'quality-level': |
|||
this.setQualityLevel(parseInt(newValue) || -1); |
|||
break; |
|||
default: |
|||
this.updateVideoAttributes(); |
|||
break; |
|||
} |
|||
} |
|||
|
|||
updateVideoAttributes() { |
|||
// 同步属性到内部 video 元素 |
|||
const videoAttributes = [ |
|||
'controls', 'autoplay', 'muted', 'loop', 'poster', |
|||
'width', 'height', 'preload', 'crossorigin', 'playsinline' |
|||
]; |
|||
|
|||
videoAttributes.forEach(attr => { |
|||
if (this.hasAttribute(attr)) { |
|||
const value = this.getAttribute(attr); |
|||
if (value === '' || value === attr) { |
|||
this.video.setAttribute(attr, ''); |
|||
} else { |
|||
this.video.setAttribute(attr, value); |
|||
} |
|||
} else { |
|||
this.video.removeAttribute(attr); |
|||
} |
|||
}); |
|||
|
|||
// 处理数值属性 |
|||
const volume = this.getAttribute('volume'); |
|||
if (volume !== null) { |
|||
this.video.volume = parseFloat(volume); |
|||
} |
|||
|
|||
const playbackRate = this.getAttribute('playback-rate'); |
|||
if (playbackRate !== null) { |
|||
this.video.playbackRate = parseFloat(playbackRate); |
|||
} |
|||
|
|||
const currentTime = this.getAttribute('current-time'); |
|||
if (currentTime !== null) { |
|||
this.video.currentTime = parseFloat(currentTime); |
|||
} |
|||
} |
|||
|
|||
async loadSource() { |
|||
const src = this.getAttribute('src'); |
|||
if (!src) return; |
|||
|
|||
this.showLoading(); |
|||
this.hideError(); |
|||
|
|||
try { |
|||
if (this.isHlsUrl(src)) { |
|||
await this.loadWithHlsJs(src); |
|||
} else { |
|||
// 非 HLS 源,直接使用 video 元素 |
|||
this.video.src = src; |
|||
} |
|||
} catch (error) { |
|||
this.showError(error.message); |
|||
} |
|||
} |
|||
|
|||
isHlsUrl(url) { |
|||
return url.includes('.m3u8') || url.includes('application/vnd.apple.mpegurl'); |
|||
} |
|||
|
|||
async loadWithHlsJs(src) { |
|||
this.destroyHls(); |
|||
|
|||
try { |
|||
const config = this.getHlsConfig(); |
|||
|
|||
if (Hls.isSupported()) { |
|||
this.hls = new Hls(config); |
|||
this.setupHlsEvents(Hls); |
|||
this.hls.loadSource(src); |
|||
this.hls.attachMedia(this.video); |
|||
} else if (this.video.canPlayType('application/vnd.apple.mpegurl')) { |
|||
// Safari 原生支持 |
|||
this.video.src = src; |
|||
this.hideLoading(); |
|||
} else { |
|||
throw new Error('您的浏览器不支持 HLS 播放'); |
|||
} |
|||
} catch (error) { |
|||
this.showError(error.message); |
|||
} |
|||
} |
|||
|
|||
getHlsConfig() { |
|||
const debug = this.hasAttribute('debug'); |
|||
const lowLatency = this.hasAttribute('low-latency'); |
|||
const maxBufferLength = parseInt(this.getAttribute('max-buffer-length')) || 30; |
|||
|
|||
return { |
|||
debug, |
|||
lowLatencyMode: lowLatency, |
|||
maxBufferLength, |
|||
enableWorker: true, |
|||
autoStartLoad: true, |
|||
startPosition: -1, |
|||
capLevelToPlayerSize: false |
|||
}; |
|||
} |
|||
|
|||
setupHlsEvents(HlsClass) { |
|||
if (!this.hls || !HlsClass) return; |
|||
|
|||
this.hls.on(HlsClass.Events.MEDIA_ATTACHED, () => { |
|||
console.log('HLS: Media attached'); |
|||
}); |
|||
|
|||
this.hls.on(HlsClass.Events.MANIFEST_PARSED, (event, data) => { |
|||
console.log('HLS: Manifest parsed', data.levels.length, 'quality levels'); |
|||
this.hideLoading(); |
|||
this.updateQualitySelector(data.levels); |
|||
|
|||
// 触发自定义事件 |
|||
this.dispatchEvent(new CustomEvent('hlsmanifestparsed', { |
|||
detail: { levels: data.levels } |
|||
})); |
|||
}); |
|||
|
|||
this.hls.on(HlsClass.Events.LEVEL_SWITCHED, (event, data) => { |
|||
console.log('HLS: Level switched to', data.level); |
|||
this.qualitySelect.value = data.level.toString(); |
|||
|
|||
this.dispatchEvent(new CustomEvent('hlslevelswitched', { |
|||
detail: { level: data.level } |
|||
})); |
|||
}); |
|||
|
|||
this.hls.on(HlsClass.Events.ERROR, (event, data) => { |
|||
console.error('HLS Error:', data); |
|||
|
|||
if (data.fatal) { |
|||
switch (data.type) { |
|||
case HlsClass.ErrorTypes.NETWORK_ERROR: |
|||
console.log('尝试恢复网络错误...'); |
|||
this.hls.startLoad(); |
|||
break; |
|||
case HlsClass.ErrorTypes.MEDIA_ERROR: |
|||
console.log('尝试恢复媒体错误...'); |
|||
this.hls.recoverMediaError(); |
|||
break; |
|||
default: |
|||
this.showError(`播放错误: ${data.details}`); |
|||
this.destroyHls(); |
|||
break; |
|||
} |
|||
} |
|||
|
|||
this.dispatchEvent(new CustomEvent('hlserror', { |
|||
detail: data |
|||
})); |
|||
}); |
|||
} |
|||
|
|||
updateQualitySelector(levels) { |
|||
// 清空现有选项 |
|||
this.qualitySelect.innerHTML = '<option value="-1">自动</option>'; |
|||
|
|||
// 添加质量级别选项 |
|||
levels.forEach((level, index) => { |
|||
const option = document.createElement('option'); |
|||
option.value = index.toString(); |
|||
option.textContent = `${level.height}p (${Math.round(level.bitrate / 1000)}k)`; |
|||
this.qualitySelect.appendChild(option); |
|||
}); |
|||
|
|||
// 显示质量选择器(如果有多个级别) |
|||
if (levels.length > 1) { |
|||
this.qualitySelector.style.display = 'block'; |
|||
} |
|||
} |
|||
|
|||
setQualityLevel(level) { |
|||
if (this.hls) { |
|||
this.hls.currentLevel = level; |
|||
} |
|||
} |
|||
|
|||
destroyHls() { |
|||
if (this.hls) { |
|||
this.hls.destroy(); |
|||
this.hls = null; |
|||
} |
|||
} |
|||
|
|||
showLoading() { |
|||
this.loadingElement.style.display = 'block'; |
|||
} |
|||
|
|||
hideLoading() { |
|||
this.loadingElement.style.display = 'none'; |
|||
} |
|||
|
|||
showError(message) { |
|||
this.errorElement.textContent = message; |
|||
this.errorElement.style.display = 'block'; |
|||
this.hideLoading(); |
|||
} |
|||
|
|||
hideError() { |
|||
this.errorElement.style.display = 'none'; |
|||
} |
|||
|
|||
handleLoadStart() { |
|||
this.hideError(); |
|||
} |
|||
|
|||
handleCanPlay() { |
|||
this.hideLoading(); |
|||
} |
|||
|
|||
handleError(event) { |
|||
const error = this.video.error; |
|||
if (error) { |
|||
let message = '视频播放出错'; |
|||
switch (error.code) { |
|||
case error.MEDIA_ERR_ABORTED: |
|||
message = '视频播放被中止'; |
|||
break; |
|||
case error.MEDIA_ERR_NETWORK: |
|||
message = '网络错误导致视频下载失败'; |
|||
break; |
|||
case error.MEDIA_ERR_DECODE: |
|||
message = '视频解码失败'; |
|||
break; |
|||
case error.MEDIA_ERR_SRC_NOT_SUPPORTED: |
|||
message = '视频格式不支持'; |
|||
break; |
|||
} |
|||
this.showError(message); |
|||
} |
|||
} |
|||
|
|||
// 公开的 API 方法 |
|||
play() { |
|||
return this.video.play(); |
|||
} |
|||
|
|||
pause() { |
|||
this.video.pause(); |
|||
} |
|||
|
|||
load() { |
|||
this.video.load(); |
|||
} |
|||
|
|||
// 获取当前质量级别 |
|||
get currentLevel() { |
|||
return this.hls ? this.hls.currentLevel : -1; |
|||
} |
|||
|
|||
// 设置质量级别 |
|||
set currentLevel(level) { |
|||
this.setQualityLevel(level); |
|||
} |
|||
|
|||
// 获取可用的质量级别 |
|||
get levels() { |
|||
return this.hls ? this.hls.levels : []; |
|||
} |
|||
|
|||
// 获取/设置音量 |
|||
get volume() { |
|||
return this.video.volume; |
|||
} |
|||
|
|||
set volume(value) { |
|||
this.video.volume = value; |
|||
} |
|||
|
|||
// 获取/设置当前时间 |
|||
get currentTime() { |
|||
return this.video.currentTime; |
|||
} |
|||
|
|||
set currentTime(value) { |
|||
this.video.currentTime = value; |
|||
} |
|||
|
|||
// 获取持续时间 |
|||
get duration() { |
|||
return this.video.duration; |
|||
} |
|||
|
|||
// 获取/设置播放速率 |
|||
get playbackRate() { |
|||
return this.video.playbackRate; |
|||
} |
|||
|
|||
set playbackRate(value) { |
|||
this.video.playbackRate = value; |
|||
} |
|||
|
|||
// 获取是否暂停 |
|||
get paused() { |
|||
return this.video.paused; |
|||
} |
|||
|
|||
// 获取是否结束 |
|||
get ended() { |
|||
return this.video.ended; |
|||
} |
|||
|
|||
// 获取缓冲范围 |
|||
get buffered() { |
|||
return this.video.buffered; |
|||
} |
|||
|
|||
// 获取网络状态 |
|||
get networkState() { |
|||
return this.video.networkState; |
|||
} |
|||
|
|||
// 获取就绪状态 |
|||
get readyState() { |
|||
return this.video.readyState; |
|||
} |
|||
} |
|||
|
|||
// 注册自定义元素 |
|||
customElements.define('jv4-hls', HlsVideo); |
|||
|
|||
export default HlsVideo; |
Write
Preview
Loading…
Cancel
Save
Reference in new issue