Browse Source

feat: add hls player

v4
langhuihui 4 weeks ago
parent
commit
d556666aac
  1. 1
      demo/components.d.ts
  2. 1
      demo/package.json
  3. 20
      demo/src/App.vue
  4. 34
      demo/src/vite-env.d.ts
  5. 316
      packages/hls/bundled-demo.html
  6. 35899
      packages/hls/dist/hls-video.js
  7. 35340
      packages/hls/dist/hls.js
  8. 35606
      packages/hls/dist/hls.mjs
  9. 10
      packages/hls/package.json
  10. 559
      packages/hls/src/hls-video.js
  11. 517
      packages/hls/src/hls-video.mjs
  12. 5
      pnpm-lock.yaml

1
demo/components.d.ts

@ -11,6 +11,7 @@ declare module 'vue' {
Decoder: typeof import('./src/components/Decoder.vue')['default']
Demuxer: typeof import('./src/components/Demuxer.vue')['default']
LogPanel: typeof import('./src/components/LogPanel.vue')['default']
NInput: typeof import('naive-ui')['NInput']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NSelect: typeof import('naive-ui')['NSelect']
NSwitch: typeof import('naive-ui')['NSwitch']

1
demo/package.json

@ -16,6 +16,7 @@
"jv4-demuxer": "workspace:*",
"jv4-renderer": "workspace:*",
"jv4-ui": "workspace:*",
"jv4-hls": "workspace:*",
"naive-ui": "^2.31.0",
"vue": "^3.2.37",
"vue-router": "4",

20
demo/src/App.vue

@ -6,14 +6,15 @@ import Demuxer from "./components/Demuxer.vue";
import Decoder from "./components/Decoder.vue";
import Renderer from "./components/Renderer.vue";
import UI from "./components/UI.vue";
import TimeRangeDemo from "./components/TimeRangeDemo.vue";
import VirtualTimelineDemo from "./components/VirtualTimelineDemo.vue";
import RealVideoTimelineDemo from "./components/RealVideoTimelineDemo.vue";
// HLS Web Component hls-video
import "jv4-hls";
import { ref } from "vue";
const m3u8 = ref("https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8");
</script>
<template>
<n-message-provider>
<n-tabs type="segment" default-value="renderer">
<n-tabs type="segment" default-value="hls">
<n-tab-pane name="connection" tab="Connection">
<Connection />
</n-tab-pane>
@ -29,14 +30,9 @@ import RealVideoTimelineDemo from "./components/RealVideoTimelineDemo.vue";
<n-tab-pane name="ui" tab="UI">
<UI />
</n-tab-pane>
<n-tab-pane name="time-range-demo" tab="Time Range Demo">
<TimeRangeDemo />
</n-tab-pane>
<n-tab-pane name="virtual-timeline" tab="虚拟时间线">
<VirtualTimelineDemo />
</n-tab-pane>
<n-tab-pane name="real-video-timeline" tab="真实视频时间线">
<RealVideoTimelineDemo />
<n-tab-pane name="hls" tab="HLS">
<jv4-hls :src="m3u8" controls width="800" debug></jv4-hls>
<n-input v-model:value="m3u8"></n-input>
</n-tab-pane>
</n-tabs>
</n-message-provider>

34
demo/src/vite-env.d.ts

@ -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;
};
}
}
}

316
packages/hls/bundled-demo.html

@ -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>&lt;script src="dist/simple-hls-video.min.js"&gt;&lt;/script&gt;
&lt;simple-hls-video src="video.m3u8" controls autoplay muted&gt;&lt;/simple-hls-video&gt;</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>&lt;script src="dist/hls-video.min.js"&gt;&lt;/script&gt;
&lt;hls-video src="video.m3u8" controls debug&gt;&lt;/hls-video&gt;</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 中使用
&lt;simple-hls-video src="video.m3u8" controls&gt;&lt;/simple-hls-video&gt;</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
&lt;template&gt;
&lt;simple-hls-video
:src="videoUrl"
controls
@play="onPlay"
@pause="onPause"
ref="player"
&gt;&lt;/simple-hls-video&gt;
&lt;/template&gt;
&lt;script setup&gt;
import { ref } from 'vue';
const player = ref(null);
const videoUrl = ref('video.m3u8');
const onPlay = () => console.log('播放开始');
const onPause = () => console.log('播放暂停');
&lt;/script&gt;</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 (
&lt;simple-hls-video
ref={playerRef}
src="video.m3u8"
controls
/&gt;
);
}</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

35340
packages/hls/dist/hls.js
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

10
packages/hls/package.json

@ -0,0 +1,10 @@
{
"name": "jv4-hls",
"version": "1.0.0",
"description": "",
"type": "module",
"module": "src/hls-video.mjs",
"keywords": [],
"author": "dexter",
"license": "ISC"
}

559
packages/hls/src/hls-video.js

@ -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;
}

517
packages/hls/src/hls-video.mjs

@ -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;

5
pnpm-lock.yaml

@ -54,6 +54,9 @@ importers:
jv4-demuxer:
specifier: workspace:*
version: link:../packages/demuxer
jv4-hls:
specifier: workspace:*
version: link:../packages/hls
jv4-renderer:
specifier: workspace:*
version: link:../packages/renderer
@ -181,6 +184,8 @@ importers:
specifier: ^4.4.9
version: 4.5.9(@types/node@22.13.4)
packages/hls: {}
packages/renderer:
devDependencies:
'@types/dom-mediacapture-transform':

Loading…
Cancel
Save