whepts 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +9 -0
- package/.vscode/settings.json +38 -0
- package/README.md +177 -0
- package/dist/index.d.ts +196 -0
- package/dist/index.js +1 -0
- package/dist/utils/observer.d.ts +8 -0
- package/eslint.config.ts +13 -0
- package/package.json +45 -0
- package/rollup.config.ts +37 -0
- package/src/index.ts +826 -0
- package/src/utils/observer.ts +28 -0
- package/tsconfig.json +38 -0
package/.editorconfig
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
// Disable the default formatter
|
|
3
|
+
"prettier.enable": false,
|
|
4
|
+
"editor.formatOnSave": false,
|
|
5
|
+
|
|
6
|
+
// Auto fix
|
|
7
|
+
"editor.codeActionsOnSave": {
|
|
8
|
+
"source.fixAll.eslint": "explicit",
|
|
9
|
+
"source.organizeImports": "never"
|
|
10
|
+
},
|
|
11
|
+
|
|
12
|
+
// Silent the stylistic rules in you IDE, but still auto fix them
|
|
13
|
+
"eslint.rules.customizations": [
|
|
14
|
+
{ "rule": "style/*", "severity": "off" },
|
|
15
|
+
{ "rule": "*-indent", "severity": "off" },
|
|
16
|
+
{ "rule": "*-spacing", "severity": "off" },
|
|
17
|
+
{ "rule": "*-spaces", "severity": "off" },
|
|
18
|
+
{ "rule": "*-order", "severity": "off" },
|
|
19
|
+
{ "rule": "*-dangle", "severity": "off" },
|
|
20
|
+
{ "rule": "*-newline", "severity": "off" },
|
|
21
|
+
{ "rule": "*quotes", "severity": "off" },
|
|
22
|
+
{ "rule": "*semi", "severity": "off" }
|
|
23
|
+
],
|
|
24
|
+
|
|
25
|
+
// The following is optional.
|
|
26
|
+
// It's better to put under project setting `.vscode/settings.json`
|
|
27
|
+
// to avoid conflicts with working with different eslint configs
|
|
28
|
+
// that does not support all formats.
|
|
29
|
+
"eslint.validate": [
|
|
30
|
+
"javascript",
|
|
31
|
+
"typescript",
|
|
32
|
+
"html",
|
|
33
|
+
"markdown",
|
|
34
|
+
"json",
|
|
35
|
+
"jsonc",
|
|
36
|
+
"yaml"
|
|
37
|
+
]
|
|
38
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# WhepTS Player
|
|
2
|
+
|
|
3
|
+
基于 [mediamtx](https://github.com/bluenviron/mediamtx) 的 WebRTC WHEP 播放器,支持 ZLM 和 Mediamtx 的播放地址。
|
|
4
|
+
|
|
5
|
+
## 简介
|
|
6
|
+
|
|
7
|
+
本项目基于 [mediamtx 的 reader.js](https://github.com/bluenviron/mediamtx/blob/main/internal/servers/webrtc/reader.js) 编写,实现了对 ZLM (ZLMediaKit) 和 Mediamtx 的 WebRTC WHEP 协议播放地址的支持。
|
|
8
|
+
|
|
9
|
+
WebRTC WHEP (WebRTC HTTP Egress Protocol) 是一种用于从 WebRTC 服务器获取媒体流的协议,允许客户端通过 HTTP 请求获取实时音视频流。
|
|
10
|
+
|
|
11
|
+
## 功能特性
|
|
12
|
+
|
|
13
|
+
- 支持 ZLMediaKit 的 WHEP 播放地址
|
|
14
|
+
- 支持 Mediamtx 的 WHEP 播放地址
|
|
15
|
+
- 基于 WebRTC 的低延迟播放
|
|
16
|
+
- TypeScript 编写,类型安全
|
|
17
|
+
- 支持事件监听和错误处理
|
|
18
|
+
- 可配置的连接参数
|
|
19
|
+
- 自动断流检测与重连
|
|
20
|
+
- 支持多种音视频编解码器
|
|
21
|
+
- 支持仅可视区域播放控制
|
|
22
|
+
- 在 Chrome 上根据硬件支持 G711 和 H265 编解码器
|
|
23
|
+
|
|
24
|
+
## 安装
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
或
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pnpm install
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 使用方法
|
|
37
|
+
|
|
38
|
+
### 基本用法
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import WebRTCWhep from 'whepts'
|
|
42
|
+
|
|
43
|
+
// 配置参数
|
|
44
|
+
const config = {
|
|
45
|
+
url: 'https://your-server:port/index/api/whep?app={app}&stream={stream}', // WHEP 服务器地址
|
|
46
|
+
container: document.getElementById('video') as HTMLMediaElement, // 视频播放容器
|
|
47
|
+
onError: (error) => {
|
|
48
|
+
console.error('播放错误:', error)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 创建播放器实例
|
|
53
|
+
const player = new WebRTCWhep(config)
|
|
54
|
+
|
|
55
|
+
// 播放器会自动开始播放
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 高级用法
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
import WebRTCWhep from 'whepts'
|
|
62
|
+
|
|
63
|
+
// 配置参数
|
|
64
|
+
const config = {
|
|
65
|
+
url: 'http://localhost:8889/{stream}/whep', // WHEP 服务器地址
|
|
66
|
+
container: document.getElementById('video') as HTMLMediaElement, // 视频播放容器
|
|
67
|
+
user: 'username', // 认证用户名(可选)
|
|
68
|
+
pass: 'password', // 认证密码(可选)
|
|
69
|
+
token: 'token', // 认证令牌(可选)
|
|
70
|
+
iceServers: [ // ICE 服务器配置(可选)
|
|
71
|
+
{
|
|
72
|
+
urls: ['stun:stun.l.google.com:19302']
|
|
73
|
+
}
|
|
74
|
+
],
|
|
75
|
+
onError: (error) => {
|
|
76
|
+
console.error('播放错误:', error)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 创建播放器实例
|
|
81
|
+
const player = new WebRTCWhep(config)
|
|
82
|
+
|
|
83
|
+
// 检查流状态
|
|
84
|
+
console.log('流状态:', player.isRunning)
|
|
85
|
+
|
|
86
|
+
// 暂停播放
|
|
87
|
+
player.pause()
|
|
88
|
+
|
|
89
|
+
// 恢复播放
|
|
90
|
+
player.resume()
|
|
91
|
+
|
|
92
|
+
// 关闭播放器
|
|
93
|
+
// player.close();
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## 支持的播放地址格式
|
|
97
|
+
|
|
98
|
+
### ZLMediaKit
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
https://zlmediakit.com/index/api/whep?app={app}&stream={stream}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Mediamtx
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
http://localhost:8889/{stream}/whep
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## API
|
|
111
|
+
|
|
112
|
+
### WebRTCWhep 类
|
|
113
|
+
|
|
114
|
+
#### 构造函数
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
WebRTCWhep(conf)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
- `conf`: 配置对象
|
|
121
|
+
- `url`: WHEP 服务器地址
|
|
122
|
+
- `container`: HTML 媒体元素容器
|
|
123
|
+
- `user`: 认证用户名(可选)
|
|
124
|
+
- `pass`: 认证密码(可选)
|
|
125
|
+
- `token`: 认证令牌(可选)
|
|
126
|
+
- `iceServers`: ICE 服务器配置(可选)
|
|
127
|
+
- `onError`: 错误回调函数(可选)
|
|
128
|
+
|
|
129
|
+
#### 属性
|
|
130
|
+
|
|
131
|
+
- `isRunning`: 流是否正在运行
|
|
132
|
+
- `paused`: 播放器是否已暂停
|
|
133
|
+
|
|
134
|
+
#### 方法
|
|
135
|
+
|
|
136
|
+
- `close()`: 关闭播放器和所有资源
|
|
137
|
+
- `pause()`: 暂停播放
|
|
138
|
+
- `resume()`: 恢复播放
|
|
139
|
+
|
|
140
|
+
#### 错误类型
|
|
141
|
+
|
|
142
|
+
- `ErrorTypes.SIGNAL_ERROR`: 信令异常
|
|
143
|
+
- `ErrorTypes.STATE_ERROR`: 状态异常
|
|
144
|
+
- `ErrorTypes.NETWORK_ERROR`: 网络错误
|
|
145
|
+
- `ErrorTypes.MEDIA_ERROR`: 媒体错误
|
|
146
|
+
- `ErrorTypes.OTHER_ERROR`: 其他错误
|
|
147
|
+
|
|
148
|
+
## 构建
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
npm run build
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## 开发
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
npm run dev
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## 项目结构
|
|
161
|
+
|
|
162
|
+
```text
|
|
163
|
+
src/
|
|
164
|
+
├── index.ts # 主要的 WebRTCWhep 类实现
|
|
165
|
+
└── utils/
|
|
166
|
+
└── observer.ts # 可见性监测工具
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## 依赖
|
|
170
|
+
|
|
171
|
+
- TypeScript
|
|
172
|
+
- WebRTC API
|
|
173
|
+
- 相关构建工具 (Rollup, ESLint)
|
|
174
|
+
|
|
175
|
+
## 许可证
|
|
176
|
+
|
|
177
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration interface for WebRTCWhep.
|
|
3
|
+
*/
|
|
4
|
+
export interface Conf {
|
|
5
|
+
/** Absolute URL of the WHEP endpoint */
|
|
6
|
+
url: string;
|
|
7
|
+
/** Media player container */
|
|
8
|
+
container: HTMLMediaElement;
|
|
9
|
+
/** Username for authentication */
|
|
10
|
+
user?: string;
|
|
11
|
+
/** Password for authentication */
|
|
12
|
+
pass?: string;
|
|
13
|
+
/** Token for authentication */
|
|
14
|
+
token?: string;
|
|
15
|
+
/** ice server list */
|
|
16
|
+
iceServers?: RTCIceServer[];
|
|
17
|
+
/** Called when there's an error */
|
|
18
|
+
onError?: (err: WebRTCError) => void;
|
|
19
|
+
}
|
|
20
|
+
/** Extend RTCConfiguration to include experimental properties */
|
|
21
|
+
declare global {
|
|
22
|
+
interface RTCConfiguration {
|
|
23
|
+
sdpSemantics?: 'plan-b' | 'unified-plan';
|
|
24
|
+
}
|
|
25
|
+
interface RTCIceServer {
|
|
26
|
+
credentialType?: 'password';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 错误类型
|
|
31
|
+
*/
|
|
32
|
+
export type ErrorType = string;
|
|
33
|
+
/**
|
|
34
|
+
* 错误类型常量
|
|
35
|
+
*/
|
|
36
|
+
export declare const ErrorTypes: {
|
|
37
|
+
SIGNAL_ERROR: string;
|
|
38
|
+
STATE_ERROR: string;
|
|
39
|
+
NETWORK_ERROR: string;
|
|
40
|
+
MEDIA_ERROR: string;
|
|
41
|
+
OTHER_ERROR: string;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* 错误
|
|
45
|
+
*/
|
|
46
|
+
export declare class WebRTCError extends Error {
|
|
47
|
+
type: ErrorType;
|
|
48
|
+
constructor(type: ErrorType, message: string, options?: ErrorOptions);
|
|
49
|
+
}
|
|
50
|
+
/** WebRTC/WHEP reader. */
|
|
51
|
+
export default class WebRTCWhep {
|
|
52
|
+
private retryPause;
|
|
53
|
+
private conf;
|
|
54
|
+
private state;
|
|
55
|
+
private restartTimeout?;
|
|
56
|
+
private pc?;
|
|
57
|
+
private offerData?;
|
|
58
|
+
private sessionUrl?;
|
|
59
|
+
private queuedCandidates;
|
|
60
|
+
private nonAdvertisedCodecs;
|
|
61
|
+
private container;
|
|
62
|
+
private observer;
|
|
63
|
+
private stream?;
|
|
64
|
+
/**
|
|
65
|
+
* 断连重试参数
|
|
66
|
+
*/
|
|
67
|
+
private checkInterval;
|
|
68
|
+
private lastBytesReceived;
|
|
69
|
+
private checkTimer?;
|
|
70
|
+
/**
|
|
71
|
+
* Create a WebRTCWhep.
|
|
72
|
+
* @param {Conf} conf - Configuration.
|
|
73
|
+
*/
|
|
74
|
+
constructor(conf: Conf);
|
|
75
|
+
/**
|
|
76
|
+
* 媒体是否正常
|
|
77
|
+
*/
|
|
78
|
+
get isRunning(): boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Close the reader and all its resources.
|
|
81
|
+
*/
|
|
82
|
+
close(): void;
|
|
83
|
+
/**
|
|
84
|
+
* Check if the browser supports a non-advertised codec.
|
|
85
|
+
*/
|
|
86
|
+
private static supportsNonAdvertisedCodec;
|
|
87
|
+
/**
|
|
88
|
+
* Unquote a credential string.
|
|
89
|
+
*/
|
|
90
|
+
private static unquoteCredential;
|
|
91
|
+
/**
|
|
92
|
+
* Convert Link header to iceServers array.
|
|
93
|
+
*/
|
|
94
|
+
private static linkToIceServers;
|
|
95
|
+
/**
|
|
96
|
+
* Parse an offer SDP into iceUfrag, icePwd, and medias.
|
|
97
|
+
*/
|
|
98
|
+
private static parseOffer;
|
|
99
|
+
/**
|
|
100
|
+
* Reserve a payload type.
|
|
101
|
+
*/
|
|
102
|
+
private static reservePayloadType;
|
|
103
|
+
/**
|
|
104
|
+
* Enable stereo PCMA/PCMU codecs.
|
|
105
|
+
*/
|
|
106
|
+
private static enableStereoPcmau;
|
|
107
|
+
/**
|
|
108
|
+
* Enable multichannel Opus codec.
|
|
109
|
+
*/
|
|
110
|
+
private static enableMultichannelOpus;
|
|
111
|
+
/**
|
|
112
|
+
* Enable L16 codec.
|
|
113
|
+
*/
|
|
114
|
+
private static enableL16;
|
|
115
|
+
/**
|
|
116
|
+
* Enable stereo Opus codec.
|
|
117
|
+
*/
|
|
118
|
+
private static enableStereoOpus;
|
|
119
|
+
/**
|
|
120
|
+
* Edit an offer SDP to enable non-advertised codecs.
|
|
121
|
+
*/
|
|
122
|
+
private static editOffer;
|
|
123
|
+
/**
|
|
124
|
+
* Generate an SDP fragment.
|
|
125
|
+
*/
|
|
126
|
+
private static generateSdpFragment;
|
|
127
|
+
/**
|
|
128
|
+
* Handle errors.
|
|
129
|
+
*/
|
|
130
|
+
private handleError;
|
|
131
|
+
/**
|
|
132
|
+
* Get non-advertised codecs.
|
|
133
|
+
*/
|
|
134
|
+
private getNonAdvertisedCodecs;
|
|
135
|
+
/**
|
|
136
|
+
* Start the WebRTC session.
|
|
137
|
+
*/
|
|
138
|
+
private start;
|
|
139
|
+
/**
|
|
140
|
+
* Generate an authorization header.
|
|
141
|
+
*/
|
|
142
|
+
private authHeader;
|
|
143
|
+
/**
|
|
144
|
+
* Request ICE servers from the endpoint.
|
|
145
|
+
*/
|
|
146
|
+
private requestICEServers;
|
|
147
|
+
/**
|
|
148
|
+
* Setup a peer connection.
|
|
149
|
+
*/
|
|
150
|
+
private setupPeerConnection;
|
|
151
|
+
/**
|
|
152
|
+
* Send an offer to the endpoint.
|
|
153
|
+
*/
|
|
154
|
+
private sendOffer;
|
|
155
|
+
/**
|
|
156
|
+
* Set a remote answer.
|
|
157
|
+
*/
|
|
158
|
+
private setAnswer;
|
|
159
|
+
/**
|
|
160
|
+
* Handle local ICE candidates.
|
|
161
|
+
*/
|
|
162
|
+
private onLocalCandidate;
|
|
163
|
+
/**
|
|
164
|
+
* Send local ICE candidates to the endpoint.
|
|
165
|
+
*/
|
|
166
|
+
private sendLocalCandidates;
|
|
167
|
+
/**
|
|
168
|
+
* Handle peer connection state changes.
|
|
169
|
+
*/
|
|
170
|
+
private onConnectionState;
|
|
171
|
+
/**
|
|
172
|
+
* 启动断流检测
|
|
173
|
+
*/
|
|
174
|
+
private startFlowCheck;
|
|
175
|
+
/**
|
|
176
|
+
* 停止断流检测
|
|
177
|
+
*/
|
|
178
|
+
private stopFlowCheck;
|
|
179
|
+
private checkFlowState;
|
|
180
|
+
/**
|
|
181
|
+
* Handle incoming tracks.
|
|
182
|
+
*/
|
|
183
|
+
private onTrack;
|
|
184
|
+
/**
|
|
185
|
+
* 流是否为空
|
|
186
|
+
*/
|
|
187
|
+
get paused(): boolean;
|
|
188
|
+
/**
|
|
189
|
+
* 暂停播放
|
|
190
|
+
*/
|
|
191
|
+
pause(): void;
|
|
192
|
+
/**
|
|
193
|
+
* 恢复播放
|
|
194
|
+
*/
|
|
195
|
+
resume(): void;
|
|
196
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
class e{start(e,t){e&&(this.stop(),this.observer=new IntersectionObserver(([e])=>t(e.isIntersecting),{threshold:.5}),this.observer.observe(e))}stop(){this.observer&&(this.observer.disconnect(),this.observer=void 0)}}const t={SIGNAL_ERROR:"SignalError",STATE_ERROR:"StateError",NETWORK_ERROR:"NetworkError",MEDIA_ERROR:"MediaError",OTHER_ERROR:"OtherError"};class s extends Error{constructor(e,t,s){super(t,s),this.type=e}}class r{constructor(t){this.retryPause=2e3,this.queuedCandidates=[],this.nonAdvertisedCodecs=[],this.checkInterval=5e3,this.lastBytesReceived=0,this.conf=t,this.state="getting_codecs",this.container=t.container,this.observer=new e,this.getNonAdvertisedCodecs()}get isRunning(){return"running"===this.state}close(){this.state="closed",this.pc?.close(),this.observer.stop(),this.stopFlowCheck(),this.restartTimeout&&clearTimeout(this.restartTimeout)}static async supportsNonAdvertisedCodec(e,n){return new Promise(i=>{const a=new RTCPeerConnection({iceServers:[]}),c="audio";let o="";a.addTransceiver(c,{direction:"recvonly"}),a.createOffer().then(i=>{if(!i.sdp)throw new s(t.SIGNAL_ERROR,"SDP not present");if(i.sdp.includes(` ${e}`))throw new s(t.SIGNAL_ERROR,"already present");const h=i.sdp.split(`m=${c}`),p=h.slice(1).map(e=>e.split("\r\n")[0].split(" ").slice(3)).reduce((e,t)=>[...e,...t],[]);o=r.reservePayloadType(p);const l=h[1].split("\r\n");return l[0]+=` ${o}`,l.splice(l.length-1,0,`a=rtpmap:${o} ${e}`),void 0!==n&&l.splice(l.length-1,0,`a=fmtp:${o} ${n}`),h[1]=l.join("\r\n"),i.sdp=h.join(`m=${c}`),a.setLocalDescription(i)}).then(()=>a.setRemoteDescription(new RTCSessionDescription({type:"answer",sdp:`v=0\r\no=- 6539324223450680508 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\na=fingerprint:sha-256 0D:9F:78:15:42:B5:4B:E6:E2:94:3E:5B:37:78:E1:4B:54:59:A3:36:3A:E5:05:EB:27:EE:8F:D2:2D:41:29:25\r\nm=${c} 9 UDP/TLS/RTP/SAVPF ${o}\r\nc=IN IP4 0.0.0.0\r\na=ice-pwd:7c3bf4770007e7432ee4ea4d697db675\r\na=ice-ufrag:29e036dc\r\na=sendonly\r\na=rtcp-mux\r\na=rtpmap:${o} ${e}\r\n${void 0!==n?`a=fmtp:${o} ${n}\r\n`:""}`}))).then(()=>i(!0)).catch(()=>i(!1)).finally(()=>a.close())})}static unquoteCredential(e){return JSON.parse(`"${e}"`)}static linkToIceServers(e){return e?e.split(", ").map(e=>{const n=e.match(/^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i);if(!n)throw new s(t.SIGNAL_ERROR,"Invalid ICE server link format");const i={urls:[n[1]]};return n[3]&&(i.username=r.unquoteCredential(n[3]),i.credential=r.unquoteCredential(n[4]),i.credentialType="password"),i}):[]}static parseOffer(e){const t={iceUfrag:"",icePwd:"",medias:[]};for(const s of e.split("\r\n"))s.startsWith("m=")?t.medias.push(s.slice(2)):""===t.iceUfrag&&s.startsWith("a=ice-ufrag:")?t.iceUfrag=s.slice(12):""===t.icePwd&&s.startsWith("a=ice-pwd:")&&(t.icePwd=s.slice(10));return t}static reservePayloadType(e){for(let t=30;t<=127;t++)if((t<=63||t>=96)&&!e.includes(t.toString())){const s=t.toString();return e.push(s),s}throw new s(t.SIGNAL_ERROR,"unable to find a free payload type")}static enableStereoPcmau(e,t){const s=t.split("\r\n");let n=r.reservePayloadType(e);return s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} PCMU/8000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} PCMA/8000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),s.join("\r\n")}static enableMultichannelOpus(e,t){const s=t.split("\r\n");let n=r.reservePayloadType(e);return s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} multiopus/48000/3`),s.splice(s.length-1,0,`a=fmtp:${n} channel_mapping=0,2,1;num_streams=2;coupled_streams=1`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} multiopus/48000/4`),s.splice(s.length-1,0,`a=fmtp:${n} channel_mapping=0,1,2,3;num_streams=2;coupled_streams=2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} multiopus/48000/5`),s.splice(s.length-1,0,`a=fmtp:${n} channel_mapping=0,4,1,2,3;num_streams=3;coupled_streams=2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} multiopus/48000/6`),s.splice(s.length-1,0,`a=fmtp:${n} channel_mapping=0,4,1,2,3,5;num_streams=4;coupled_streams=2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} multiopus/48000/7`),s.splice(s.length-1,0,`a=fmtp:${n} channel_mapping=0,4,1,2,3,5,6;num_streams=4;coupled_streams=4`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} multiopus/48000/8`),s.splice(s.length-1,0,`a=fmtp:${n} channel_mapping=0,6,1,4,5,2,3,7;num_streams=5;coupled_streams=4`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),s.join("\r\n")}static enableL16(e,t){const s=t.split("\r\n");let n=r.reservePayloadType(e);return s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} L16/8000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} L16/16000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} L16/48000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),s.join("\r\n")}static enableStereoOpus(e){let t="";const s=e.split("\r\n");for(let e=0;e<s.length;e++)if(s[e].startsWith("a=rtpmap:")&&s[e].toLowerCase().includes("opus/")){t=s[e].slice(9).split(" ")[0];break}if(""===t)return e;for(let e=0;e<s.length;e++)s[e].startsWith(`a=fmtp:${t} `)&&(s[e].includes("stereo")||(s[e]+=";stereo=1"),s[e].includes("sprop-stereo")||(s[e]+=";sprop-stereo=1"));return s.join("\r\n")}static editOffer(e,t){const s=e.split("m="),n=s.slice(1).map(e=>e.split("\r\n")[0].split(" ").slice(3)).reduce((e,t)=>[...e,...t],[]);for(let e=1;e<s.length;e++)if(s[e].startsWith("audio")){s[e]=r.enableStereoOpus(s[e]),t.includes("pcma/8000/2")&&(s[e]=r.enableStereoPcmau(n,s[e])),t.includes("multiopus/48000/6")&&(s[e]=r.enableMultichannelOpus(n,s[e])),t.includes("L16/48000/2")&&(s[e]=r.enableL16(n,s[e]));break}return s.join("m=")}static generateSdpFragment(e,t){const s={};for(const e of t){const t=e.sdpMLineIndex;t&&(void 0===s[t]&&(s[t]=[]),s[t].push(e))}let r=`a=ice-ufrag:${e.iceUfrag}\r\na=ice-pwd:${e.icePwd}\r\n`,n=0;for(const t of e.medias){if(void 0!==s[n]){r+=`m=${t}\r\na=mid:${n}\r\n`;for(const e of s[n])r+=`a=${e.candidate}\r\n`}n++}return r}handleError(e){this.stopFlowCheck(),"getting_codecs"===this.state||e instanceof s&&e.type===t.SIGNAL_ERROR?this.state="failed":"running"===this.state&&(this.pc?.close(),this.pc=void 0,this.offerData=void 0,this.queuedCandidates=[],this.sessionUrl&&(fetch(this.sessionUrl,{method:"DELETE"}),this.sessionUrl=void 0),this.state="restarting",this.restartTimeout=setTimeout(()=>{this.restartTimeout=void 0,this.state="running",this.start()},this.retryPause),e.message=`${e.message}, retrying in some seconds`),this.conf.onError&&(e instanceof s?this.conf.onError(e):this.conf.onError(new s(t.OTHER_ERROR,e.message)))}getNonAdvertisedCodecs(){Promise.all([["pcma/8000/2"],["multiopus/48000/6","channel_mapping=0,4,1,2,3,5;num_streams=4;coupled_streams=2"],["L16/48000/2"]].map(e=>r.supportsNonAdvertisedCodec(e[0],e[1]).then(t=>!!t&&e[0]))).then(e=>e.filter(e=>!1!==e)).then(e=>{if("getting_codecs"!==this.state)throw new s(t.STATE_ERROR,"closed");this.nonAdvertisedCodecs=e,this.state="running",this.start()}).catch(e=>this.handleError(e))}start(){this.requestICEServers().then(e=>this.setupPeerConnection(e)).then(e=>this.sendOffer(e)).then(e=>this.setAnswer(e)).catch(e=>this.handleError(e))}authHeader(){if(this.conf.user&&""!==this.conf.user){return{Authorization:`Basic ${btoa(`${this.conf.user}:${this.conf.pass}`)}`}}return this.conf.token&&""!==this.conf.token?{Authorization:`Bearer ${this.conf.token}`}:{}}async requestICEServers(){return this.conf.iceServers&&this.conf.iceServers.length>0?this.conf.iceServers:fetch(this.conf.url,{method:"OPTIONS",headers:{...this.authHeader()}}).then(e=>r.linkToIceServers(e.headers.get("Link")))}async setupPeerConnection(e){if("running"!==this.state)throw new s(t.STATE_ERROR,"closed");const n=new RTCPeerConnection({iceServers:e,sdpSemantics:"unified-plan"});this.pc=n;const i="recvonly";return n.addTransceiver("video",{direction:i}),n.addTransceiver("audio",{direction:i}),n.onicecandidate=e=>this.onLocalCandidate(e),n.onconnectionstatechange=()=>this.onConnectionState(),n.ontrack=e=>this.onTrack(e),n.createOffer().then(e=>{if(!e.sdp)throw new s(t.SIGNAL_ERROR,"Failed to create offer SDP");return e.sdp=r.editOffer(e.sdp,this.nonAdvertisedCodecs),this.offerData=r.parseOffer(e.sdp),n.setLocalDescription(e).then(()=>e.sdp)})}sendOffer(e){if("running"!==this.state)throw new s(t.STATE_ERROR,"closed");return fetch(this.conf.url,{method:"POST",headers:{...this.authHeader(),"Content-Type":"application/sdp"},body:e}).then(e=>{switch(e.status){case 201:break;case 404:throw new s(t.NETWORK_ERROR,"stream not found");case 406:throw new s(t.NETWORK_ERROR,"stream not supported");case 400:return e.json().then(e=>{throw new s(t.NETWORK_ERROR,e.error)});default:throw new s(t.NETWORK_ERROR,`bad status code ${e.status}`)}const r=e.headers.get("Location");return r&&(this.sessionUrl=new URL(r,this.conf.url).toString()),e.text()})}setAnswer(e){if("running"!==this.state)throw new s(t.STATE_ERROR,"closed");return this.pc.setRemoteDescription(new RTCSessionDescription({type:"answer",sdp:e})).then(()=>{"running"===this.state&&0!==this.queuedCandidates.length&&(this.sendLocalCandidates(this.queuedCandidates),this.queuedCandidates=[])})}onLocalCandidate(e){"running"===this.state&&e.candidate&&(this.sessionUrl?this.sendLocalCandidates([e.candidate]):this.queuedCandidates.push(e.candidate))}sendLocalCandidates(e){this.sessionUrl&&this.offerData&&fetch(this.sessionUrl,{method:"PATCH",headers:{"Content-Type":"application/trickle-ice-sdpfrag","If-Match":"*"},body:r.generateSdpFragment(this.offerData,e)}).then(e=>{switch(e.status){case 204:break;case 404:throw new s(t.NETWORK_ERROR,"stream not found");default:throw new s(t.NETWORK_ERROR,`bad status code ${e.status}`)}}).catch(e=>this.handleError(e))}onConnectionState(){"running"===this.state&&this.pc&&("failed"===this.pc.connectionState||"closed"===this.pc.connectionState?this.handleError(new s(t.OTHER_ERROR,"peer connection closed")):"connected"===this.pc.connectionState&&this.startFlowCheck())}startFlowCheck(){this.stopFlowCheck(),this.checkTimer=setInterval(()=>this.checkFlowState(),this.checkInterval)}stopFlowCheck(){this.checkTimer&&(clearInterval(this.checkTimer),this.checkTimer=void 0)}async checkFlowState(){if(!this.pc)return;const e=await this.pc.getStats();let r=0;e.forEach(e=>{const t=e;"inbound-rtp"===e.type&&"video"===t.kind&&(r=t.bytesReceived||0)}),r!==this.lastBytesReceived||"connected"!==this.pc.connectionState?this.lastBytesReceived=r:this.handleError(new s(t.NETWORK_ERROR,"data stream interruption"))}onTrack(e){this.stream=e.streams[0],this.observer.start(this.container,e=>{e?this.resume():this.pause()})}get paused(){return null===this.container.srcObject}pause(){this.container.srcObject=null}resume(){this.stream&&this.paused&&(this.container.srcObject=this.stream)}}export{t as ErrorTypes,s as WebRTCError,r as default};
|
package/eslint.config.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "whepts",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"packageManager": "pnpm@10.26.1",
|
|
6
|
+
"description": "",
|
|
7
|
+
"author": "mapleafgo",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"webrtc",
|
|
11
|
+
"whep",
|
|
12
|
+
"player",
|
|
13
|
+
"streaming",
|
|
14
|
+
"mediamtx",
|
|
15
|
+
"zlmediakit",
|
|
16
|
+
"typescript",
|
|
17
|
+
"video",
|
|
18
|
+
"audio",
|
|
19
|
+
"realtime",
|
|
20
|
+
"low-latency",
|
|
21
|
+
"media"
|
|
22
|
+
],
|
|
23
|
+
"main": "dist/index.js",
|
|
24
|
+
"types": "dist/index.d.ts",
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "rollup --config --environment NODE_ENV:production",
|
|
27
|
+
"build:debug": "rollup --config",
|
|
28
|
+
"lint": "eslint",
|
|
29
|
+
"lint:fix": "eslint --fix"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@antfu/eslint-config": "^6.7.3",
|
|
33
|
+
"@rollup/plugin-commonjs": "^29.0.0",
|
|
34
|
+
"@rollup/plugin-eslint": "^9.2.0",
|
|
35
|
+
"@rollup/plugin-terser": "^0.4.4",
|
|
36
|
+
"@rollup/plugin-typescript": "^12.3.0",
|
|
37
|
+
"@types/node": "^25.0.3",
|
|
38
|
+
"eslint": "^9.39.2",
|
|
39
|
+
"eslint-plugin-format": "^1.1.0",
|
|
40
|
+
"rollup": "^4.54.0",
|
|
41
|
+
"rollup-plugin-delete": "^3.0.2",
|
|
42
|
+
"tslib": "^2.8.1",
|
|
43
|
+
"typescript": "^5.9.3"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/rollup.config.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import commonjs from '@rollup/plugin-commonjs'
|
|
2
|
+
import eslint from '@rollup/plugin-eslint'
|
|
3
|
+
import terser from '@rollup/plugin-terser'
|
|
4
|
+
import typescript from '@rollup/plugin-typescript'
|
|
5
|
+
import del from 'rollup-plugin-delete'
|
|
6
|
+
|
|
7
|
+
const isProduction = process.env.NODE_ENV === 'production'
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
input: 'src/index.ts',
|
|
11
|
+
output: [
|
|
12
|
+
{
|
|
13
|
+
file: 'dist/index.js',
|
|
14
|
+
format: 'es',
|
|
15
|
+
sourcemap: !isProduction,
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
external: [],
|
|
19
|
+
plugins: [
|
|
20
|
+
del({ targets: 'dist' }),
|
|
21
|
+
commonjs(),
|
|
22
|
+
eslint({
|
|
23
|
+
fix: true,
|
|
24
|
+
}),
|
|
25
|
+
typescript({
|
|
26
|
+
importHelpers: true,
|
|
27
|
+
sourceMap: !isProduction,
|
|
28
|
+
}),
|
|
29
|
+
isProduction && terser({
|
|
30
|
+
compress: {
|
|
31
|
+
drop_console: true,
|
|
32
|
+
drop_debugger: true,
|
|
33
|
+
},
|
|
34
|
+
mangle: true,
|
|
35
|
+
}),
|
|
36
|
+
].filter(Boolean),
|
|
37
|
+
}
|