pluto-rtc 0.0.2
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/README.md +84 -0
- package/dist/Connection.d.ts +35 -0
- package/dist/Connection.js +146 -0
- package/dist/ConnectionManager.d.ts +38 -0
- package/dist/ConnectionManager.js +78 -0
- package/dist/api/MediaTransport.d.ts +7 -0
- package/dist/api/MediaTransport.js +262 -0
- package/dist/api/PlutoPeerConnection.d.ts +38 -0
- package/dist/api/PlutoPeerConnection.js +242 -0
- package/dist/api/PlutoWebSocket.d.ts +24 -0
- package/dist/api/PlutoWebSocket.js +112 -0
- package/dist/api/PlutoWebTransport.d.ts +28 -0
- package/dist/api/PlutoWebTransport.js +88 -0
- package/dist/core/Client.d.ts +70 -0
- package/dist/core/Client.js +326 -0
- package/dist/core/Connection.d.ts +66 -0
- package/dist/core/Connection.js +392 -0
- package/dist/core/Room.d.ts +29 -0
- package/dist/core/Room.js +297 -0
- package/dist/core/Signaling.d.ts +37 -0
- package/dist/core/Signaling.js +199 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/react/index.d.ts +9 -0
- package/dist/react/index.js +34 -0
- package/package.json +47 -0
- package/wasm/pkg/README.md +218 -0
- package/wasm/pkg/iroh_wasm.d.ts +148 -0
- package/wasm/pkg/iroh_wasm.js +1382 -0
- package/wasm/pkg/iroh_wasm_bg.wasm +0 -0
- package/wasm/pkg/iroh_wasm_bg.wasm.d.ts +58 -0
- package/wasm/pkg/package.json +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# PlutoRTC
|
|
2
|
+
|
|
3
|
+
A bare bones package for developers to create RTC apps quickly and painlessly.
|
|
4
|
+
Leverages Pluto/Iroh for connectivity and Firebase for signaling/auth.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install pluto-rtc
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
### Initialization
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import { ConnectionManager } from 'pluto-rtc';
|
|
18
|
+
|
|
19
|
+
const rtc = new ConnectionManager({
|
|
20
|
+
firebaseConfig: { ... }, // Your Firebase Config
|
|
21
|
+
tag: 'my-awesome-game'
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Initialize and Listen
|
|
25
|
+
await rtc.startListening();
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Authentication
|
|
29
|
+
|
|
30
|
+
Redirects to the configured auth provider (e.g. Plutonium Identity).
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
rtc.signIn(); // Triggers redirect
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Discovery & Connection
|
|
37
|
+
|
|
38
|
+
Connect to other devices belonging to the same user:
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
// Search for my devices
|
|
42
|
+
const devices = await rtc.searchDevices();
|
|
43
|
+
|
|
44
|
+
// Connect to a device
|
|
45
|
+
const conn = await rtc.connect(devices[0].ticket);
|
|
46
|
+
|
|
47
|
+
conn.send("Hello World!");
|
|
48
|
+
|
|
49
|
+
conn.onMessage((msg) => {
|
|
50
|
+
console.log("Received:", msg);
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Rooms (Multi-User / Custom Signaling)
|
|
55
|
+
|
|
56
|
+
Create a room for others to join (using short IDs):
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
// Host a room
|
|
60
|
+
const roomId = await rtc.createRoom('my-game-rooms');
|
|
61
|
+
console.log(`Room created: ${roomId}`);
|
|
62
|
+
|
|
63
|
+
// Handle join requests
|
|
64
|
+
rtc.onRoomJoinRequest(async (req) => {
|
|
65
|
+
console.log(`User ${req.userId} wants to join`);
|
|
66
|
+
await rtc.acceptJoinRequest(roomId, req.id, 'my-game-rooms');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
rtc.onConnection((conn) => {
|
|
70
|
+
console.log("New peer connected:", conn.id);
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Join a room:
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
// Join a room
|
|
78
|
+
try {
|
|
79
|
+
const conn = await rtc.joinRoom('ROOM_ID', 'my-game-rooms');
|
|
80
|
+
console.log("Joined room!");
|
|
81
|
+
} catch (e) {
|
|
82
|
+
console.error("Join rejected or failed", e);
|
|
83
|
+
}
|
|
84
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface ConnectionOptions {
|
|
2
|
+
reliable?: boolean;
|
|
3
|
+
}
|
|
4
|
+
export declare class Connection {
|
|
5
|
+
private listeners;
|
|
6
|
+
private closeListeners;
|
|
7
|
+
private writer;
|
|
8
|
+
private reader;
|
|
9
|
+
private isClosed;
|
|
10
|
+
private receiveBuffer;
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly deviceId: string;
|
|
13
|
+
constructor(id: string, deviceId: string, writer: WritableStreamDefaultWriter<Uint8Array>, reader: ReadableStreamDefaultReader<Uint8Array>);
|
|
14
|
+
/**
|
|
15
|
+
* Send a message to the other peer.
|
|
16
|
+
* Messages can be strings or objects (JSON stringified).
|
|
17
|
+
*/
|
|
18
|
+
send(message: string | object | ArrayBuffer | Uint8Array): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Disconnect the connection
|
|
21
|
+
*/
|
|
22
|
+
disconnect(): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Listen for incoming messages
|
|
25
|
+
*/
|
|
26
|
+
onMessage(callback: (message: any) => void): void;
|
|
27
|
+
/**
|
|
28
|
+
* Listen for disconnection
|
|
29
|
+
*/
|
|
30
|
+
onDisconnect(callback: () => void): void;
|
|
31
|
+
private close;
|
|
32
|
+
private readLoop;
|
|
33
|
+
private handleData;
|
|
34
|
+
private emitMessage;
|
|
35
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
export class Connection {
|
|
2
|
+
constructor(id, deviceId, writer, reader) {
|
|
3
|
+
this.listeners = [];
|
|
4
|
+
this.closeListeners = [];
|
|
5
|
+
// We'll keep track of the internal stream
|
|
6
|
+
this.writer = null;
|
|
7
|
+
this.reader = null;
|
|
8
|
+
this.isClosed = false;
|
|
9
|
+
// Buffer for incoming data
|
|
10
|
+
this.receiveBuffer = new Uint8Array(0);
|
|
11
|
+
this.id = id;
|
|
12
|
+
this.deviceId = deviceId;
|
|
13
|
+
this.writer = writer;
|
|
14
|
+
this.reader = reader;
|
|
15
|
+
this.readLoop();
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Send a message to the other peer.
|
|
19
|
+
* Messages can be strings or objects (JSON stringified).
|
|
20
|
+
*/
|
|
21
|
+
async send(message) {
|
|
22
|
+
if (this.isClosed || !this.writer) {
|
|
23
|
+
throw new Error('Connection is closed');
|
|
24
|
+
}
|
|
25
|
+
let payload;
|
|
26
|
+
if (typeof message === 'string') {
|
|
27
|
+
payload = new TextEncoder().encode(message);
|
|
28
|
+
}
|
|
29
|
+
else if (message instanceof Uint8Array) {
|
|
30
|
+
payload = message;
|
|
31
|
+
}
|
|
32
|
+
else if (message instanceof ArrayBuffer) {
|
|
33
|
+
payload = new Uint8Array(message);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
payload = new TextEncoder().encode(JSON.stringify(message));
|
|
37
|
+
}
|
|
38
|
+
// Framing: 4 bytes length (Big Endian) + payload
|
|
39
|
+
const frame = new Uint8Array(4 + payload.length);
|
|
40
|
+
const view = new DataView(frame.buffer);
|
|
41
|
+
view.setUint32(0, payload.length, false);
|
|
42
|
+
frame.set(payload, 4);
|
|
43
|
+
try {
|
|
44
|
+
await this.writer.write(frame);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
console.error('[Connection] Send error:', err);
|
|
48
|
+
this.close();
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Disconnect the connection
|
|
54
|
+
*/
|
|
55
|
+
async disconnect() {
|
|
56
|
+
this.close();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Listen for incoming messages
|
|
60
|
+
*/
|
|
61
|
+
onMessage(callback) {
|
|
62
|
+
this.listeners.push(callback);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Listen for disconnection
|
|
66
|
+
*/
|
|
67
|
+
onDisconnect(callback) {
|
|
68
|
+
this.closeListeners.push(callback);
|
|
69
|
+
}
|
|
70
|
+
close() {
|
|
71
|
+
if (this.isClosed)
|
|
72
|
+
return;
|
|
73
|
+
this.isClosed = true;
|
|
74
|
+
// Close writer/reader
|
|
75
|
+
this.writer?.close().catch(() => { });
|
|
76
|
+
this.reader?.cancel().catch(() => { });
|
|
77
|
+
this.writer = null;
|
|
78
|
+
this.reader = null;
|
|
79
|
+
// Notify listeners
|
|
80
|
+
this.closeListeners.forEach(cb => cb());
|
|
81
|
+
}
|
|
82
|
+
async readLoop() {
|
|
83
|
+
if (!this.reader)
|
|
84
|
+
return;
|
|
85
|
+
try {
|
|
86
|
+
while (!this.isClosed) {
|
|
87
|
+
const { done, value } = await this.reader.read();
|
|
88
|
+
if (done) {
|
|
89
|
+
this.close();
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
if (value) {
|
|
93
|
+
this.handleData(value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
console.error('[Connection] Read loop error:', err);
|
|
99
|
+
this.close();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
handleData(chunk) {
|
|
103
|
+
// Append to buffer
|
|
104
|
+
const newBuffer = new Uint8Array(this.receiveBuffer.length + chunk.length);
|
|
105
|
+
newBuffer.set(this.receiveBuffer);
|
|
106
|
+
newBuffer.set(chunk, this.receiveBuffer.length);
|
|
107
|
+
this.receiveBuffer = newBuffer;
|
|
108
|
+
// Process messages
|
|
109
|
+
while (true) {
|
|
110
|
+
if (this.receiveBuffer.length < 4)
|
|
111
|
+
break; // Need at least length header
|
|
112
|
+
const view = new DataView(this.receiveBuffer.buffer, this.receiveBuffer.byteOffset, this.receiveBuffer.byteLength);
|
|
113
|
+
const length = view.getUint32(0, false);
|
|
114
|
+
if (this.receiveBuffer.length < 4 + length)
|
|
115
|
+
break; // Wait for full message
|
|
116
|
+
// Extract message
|
|
117
|
+
const messageData = this.receiveBuffer.slice(4, 4 + length);
|
|
118
|
+
this.receiveBuffer = this.receiveBuffer.slice(4 + length);
|
|
119
|
+
// Check if it's text/JSON
|
|
120
|
+
try {
|
|
121
|
+
const text = new TextDecoder().decode(messageData);
|
|
122
|
+
// Try to parse JSON, if it looks like object/array, return parsed
|
|
123
|
+
// Otherwise return string
|
|
124
|
+
try {
|
|
125
|
+
const json = JSON.parse(text);
|
|
126
|
+
if (typeof json === 'object' && json !== null) {
|
|
127
|
+
this.emitMessage(json);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
this.emitMessage(text);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
this.emitMessage(text);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Validation failed, send raw? For now assume it wraps text/json
|
|
139
|
+
this.emitMessage(messageData);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
emitMessage(msg) {
|
|
144
|
+
this.listeners.forEach(cb => cb(msg));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ClientOptions } from './core/Client';
|
|
2
|
+
import { Connection } from './core/Connection';
|
|
3
|
+
import { JoinRequest } from './core/Room';
|
|
4
|
+
/**
|
|
5
|
+
* @deprecated Use Client instead. This class is a backward-compatible wrapper.
|
|
6
|
+
*/
|
|
7
|
+
export declare class ConnectionManager {
|
|
8
|
+
private client;
|
|
9
|
+
constructor(options: ClientOptions);
|
|
10
|
+
get currentUser(): import("@firebase/auth").User | null;
|
|
11
|
+
onAuthChange(callback: (user: any) => void): void;
|
|
12
|
+
init(): Promise<void>;
|
|
13
|
+
connect(ticket: string): Promise<Connection>;
|
|
14
|
+
getConnections(): Connection[];
|
|
15
|
+
getTicket(): Promise<string>;
|
|
16
|
+
getNodeId(): Promise<string>;
|
|
17
|
+
startListening(): Promise<void>;
|
|
18
|
+
stopListening(): void;
|
|
19
|
+
searchDevices(): Promise<{
|
|
20
|
+
deviceId: string;
|
|
21
|
+
deviceName: string;
|
|
22
|
+
online: boolean;
|
|
23
|
+
ticket: string;
|
|
24
|
+
}[]>;
|
|
25
|
+
onDevicesChange(callback: any): () => void;
|
|
26
|
+
createRoom(): Promise<string>;
|
|
27
|
+
joinRoom(roomId: string): Promise<Connection[]>;
|
|
28
|
+
leaveRoom(roomId: string): Promise<void>;
|
|
29
|
+
onConnection(callback: (conn: Connection) => void): void;
|
|
30
|
+
onDisconnection(callback: (conn: Connection) => void): void;
|
|
31
|
+
onMessage(callback: (conn: Connection, msg: any) => void): void;
|
|
32
|
+
onRoomJoinRequest(callback: (request: JoinRequest) => void): void;
|
|
33
|
+
signInWithPluto(): void;
|
|
34
|
+
signOut(): Promise<void>;
|
|
35
|
+
signInAnonymously(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
export * from './core/Connection';
|
|
38
|
+
export * from './core/Client';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Client } from './core/Client';
|
|
2
|
+
/**
|
|
3
|
+
* @deprecated Use Client instead. This class is a backward-compatible wrapper.
|
|
4
|
+
*/
|
|
5
|
+
export class ConnectionManager {
|
|
6
|
+
constructor(options) {
|
|
7
|
+
this.client = new Client(options);
|
|
8
|
+
}
|
|
9
|
+
get currentUser() {
|
|
10
|
+
return this.client.signaling.currentUser;
|
|
11
|
+
}
|
|
12
|
+
onAuthChange(callback) {
|
|
13
|
+
return this.client.signaling.onAuthChange(callback);
|
|
14
|
+
}
|
|
15
|
+
async init() {
|
|
16
|
+
return this.client.init();
|
|
17
|
+
}
|
|
18
|
+
async connect(ticket) {
|
|
19
|
+
return this.client.connect(ticket);
|
|
20
|
+
}
|
|
21
|
+
getConnections() {
|
|
22
|
+
return this.client.getConnections();
|
|
23
|
+
}
|
|
24
|
+
async getTicket() {
|
|
25
|
+
return this.client.getTicket();
|
|
26
|
+
}
|
|
27
|
+
async getNodeId() {
|
|
28
|
+
return this.client.getNodeId();
|
|
29
|
+
}
|
|
30
|
+
async startListening() {
|
|
31
|
+
return this.client.startListening();
|
|
32
|
+
}
|
|
33
|
+
stopListening() {
|
|
34
|
+
return this.client.stopListening();
|
|
35
|
+
}
|
|
36
|
+
// Discovery (delegated to Signaling)
|
|
37
|
+
async searchDevices() {
|
|
38
|
+
return this.client.signaling.searchDevices();
|
|
39
|
+
}
|
|
40
|
+
onDevicesChange(callback) {
|
|
41
|
+
return this.client.signaling.onDevicesChange(callback);
|
|
42
|
+
}
|
|
43
|
+
// Rooms (delegated to RoomManager)
|
|
44
|
+
async createRoom() {
|
|
45
|
+
return this.client.rooms.createRoom();
|
|
46
|
+
}
|
|
47
|
+
async joinRoom(roomId) {
|
|
48
|
+
return this.client.rooms.joinRoom(roomId);
|
|
49
|
+
}
|
|
50
|
+
async leaveRoom(roomId) {
|
|
51
|
+
return this.client.rooms.leaveRoom(roomId);
|
|
52
|
+
}
|
|
53
|
+
// Listeners
|
|
54
|
+
onConnection(callback) {
|
|
55
|
+
this.client.onConnection(callback);
|
|
56
|
+
}
|
|
57
|
+
onDisconnection(callback) {
|
|
58
|
+
this.client.onDisconnection(callback);
|
|
59
|
+
}
|
|
60
|
+
onMessage(callback) {
|
|
61
|
+
this.client.onMessage(callback);
|
|
62
|
+
}
|
|
63
|
+
onRoomJoinRequest(callback) {
|
|
64
|
+
this.client.onRoomJoinRequest(callback);
|
|
65
|
+
}
|
|
66
|
+
// Auth helpers
|
|
67
|
+
signInWithPluto() {
|
|
68
|
+
this.client.signaling.signInWithPluto();
|
|
69
|
+
}
|
|
70
|
+
async signOut() {
|
|
71
|
+
await this.client.signaling.signOut();
|
|
72
|
+
}
|
|
73
|
+
async signInAnonymously() {
|
|
74
|
+
await this.client.signaling.signInAnonymously();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export * from './core/Connection';
|
|
78
|
+
export * from './core/Client'; // Export Client so users can switch
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare class MediaTransport {
|
|
2
|
+
static sendTrack(track: MediaStreamTrack, sendFn: (data: Uint8Array) => Promise<void>): Promise<{
|
|
3
|
+
stop: () => void;
|
|
4
|
+
requestKeyFrame: () => void;
|
|
5
|
+
} | void>;
|
|
6
|
+
static receiveTrack(kind: 'audio' | 'video', onKeyFrameRequest?: () => void): MediaStreamTrack | null;
|
|
7
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Protocol-ish logic for transforming MediaStreamTracks into Chunks and back.
|
|
2
|
+
// Since we don't have VideoEncoder/VideoDecoder in standard ts environment (it's web only),
|
|
3
|
+
// we will just define the interfaces or classes here.
|
|
4
|
+
// We use type 'any' for WebCodecs interfaces to avoid TS errors in environemnts without @types/dom-webcodecs
|
|
5
|
+
export class MediaTransport {
|
|
6
|
+
// Encodes a track into the connection
|
|
7
|
+
static async sendTrack(track, sendFn) {
|
|
8
|
+
console.log(`[MediaTransport] sendTrack called for ${track.kind}`);
|
|
9
|
+
if ((typeof VideoEncoder === 'undefined') && (typeof AudioEncoder === 'undefined')) {
|
|
10
|
+
console.error("WebCodecs API not supported.");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const processor = new window.MediaStreamTrackProcessor({ track });
|
|
14
|
+
const reader = processor.readable.getReader();
|
|
15
|
+
// Very basic encoder setup.
|
|
16
|
+
// In reality we need to negotiate codecs. We assume VP8 for video and Opus for audio.
|
|
17
|
+
let encoder = null;
|
|
18
|
+
if (track.kind === 'video') {
|
|
19
|
+
let seqNum = 0;
|
|
20
|
+
encoder = new window.VideoEncoder({
|
|
21
|
+
output: (chunk, metadata) => {
|
|
22
|
+
if (chunk.type === 'key') {
|
|
23
|
+
console.log(`[MediaTransport] Generated KEY frame for video. Size: ${chunk.byteLength}`);
|
|
24
|
+
}
|
|
25
|
+
// console.log(`[MediaTransport] Encoded video chunk. Key: ${chunk.type}, Size: ${chunk.byteLength}`);
|
|
26
|
+
// Send chunk
|
|
27
|
+
// We need to serialize the chunk.
|
|
28
|
+
// [MAGIC 1b][timestamp 8 bytes][isKey 1 byte][seq 2 bytes][data...]
|
|
29
|
+
const buffer = new ArrayBuffer(12 + chunk.byteLength);
|
|
30
|
+
const view = new DataView(buffer);
|
|
31
|
+
view.setUint8(0, 0x77); // Magic byte
|
|
32
|
+
view.setBigInt64(1, BigInt(chunk.timestamp), false); // Big Endian
|
|
33
|
+
view.setUint8(9, chunk.type === 'key' ? 1 : 0);
|
|
34
|
+
const seq = (seqNum++) % 65536;
|
|
35
|
+
view.setUint16(10, seq, false);
|
|
36
|
+
const data = new Uint8Array(buffer);
|
|
37
|
+
chunk.copyTo(new Uint8Array(buffer, 12));
|
|
38
|
+
sendFn(data).catch(e => {
|
|
39
|
+
const msg = (e.message || String(e)).toLowerCase();
|
|
40
|
+
if (msg.includes("connection is closed") || msg.includes("connection closed") || msg.includes("sending stopped by peer")) {
|
|
41
|
+
console.log(`[MediaTransport] Connection closed/stopped, stopping video encoder.`);
|
|
42
|
+
if (encoder) {
|
|
43
|
+
encoder.close();
|
|
44
|
+
encoder = null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
console.error("Send media failed", e);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
error: (e) => console.error("VideoEncoder error", e)
|
|
53
|
+
});
|
|
54
|
+
encoder.configure({
|
|
55
|
+
codec: 'vp8',
|
|
56
|
+
width: 640,
|
|
57
|
+
height: 480,
|
|
58
|
+
bitrate: 500000, // 500 Kbps - Reduce congestion
|
|
59
|
+
framerate: 24, // slightly lower framerate
|
|
60
|
+
latencyMode: 'realtime', // Critical for low latency streaming
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
else if (track.kind === 'audio') {
|
|
64
|
+
encoder = new window.AudioEncoder({
|
|
65
|
+
output: (chunk, metadata) => {
|
|
66
|
+
const buffer = new ArrayBuffer(9 + chunk.byteLength);
|
|
67
|
+
const view = new DataView(buffer);
|
|
68
|
+
view.setUint8(0, 0x77); // Magic
|
|
69
|
+
view.setBigInt64(1, BigInt(chunk.timestamp), false);
|
|
70
|
+
const data = new Uint8Array(buffer);
|
|
71
|
+
chunk.copyTo(new Uint8Array(buffer, 9));
|
|
72
|
+
sendFn(data).catch(e => {
|
|
73
|
+
const msg = (e.message || String(e)).toLowerCase();
|
|
74
|
+
if (msg.includes("connection is closed") || msg.includes("connection closed")) {
|
|
75
|
+
console.log(`[MediaTransport] Connection closed, stopping audio encoder.`);
|
|
76
|
+
if (encoder) {
|
|
77
|
+
encoder.close();
|
|
78
|
+
encoder = null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
console.error("Send audio failed", e);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
error: (e) => console.error("AudioEncoder error", e)
|
|
87
|
+
});
|
|
88
|
+
encoder.configure({
|
|
89
|
+
codec: 'opus',
|
|
90
|
+
sampleRate: 48000,
|
|
91
|
+
numberOfChannels: 1
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
// Read loop - Run asynchronously so we can return the controller immediately
|
|
95
|
+
(async () => {
|
|
96
|
+
try {
|
|
97
|
+
while (true) {
|
|
98
|
+
const { done, value } = await reader.read();
|
|
99
|
+
if (done) {
|
|
100
|
+
console.log(`[MediaTransport] Reader done for ${track.kind}`);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
if (value) {
|
|
104
|
+
try {
|
|
105
|
+
if (encoder && encoder.state !== 'closed') {
|
|
106
|
+
const forceKey = encoder.forceKeyFrame;
|
|
107
|
+
if (forceKey) {
|
|
108
|
+
console.log(`[MediaTransport] Forcing Key Frame now.`);
|
|
109
|
+
encoder.encode(value, { keyFrame: true });
|
|
110
|
+
encoder.forceKeyFrame = false;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
encoder.encode(value);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
value.close();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
console.error(`[MediaTransport] Read loop error for ${track.kind}:`, e);
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
127
|
+
// Return controller
|
|
128
|
+
return {
|
|
129
|
+
stop: () => {
|
|
130
|
+
// TODO: Close processor and reader
|
|
131
|
+
},
|
|
132
|
+
requestKeyFrame: () => {
|
|
133
|
+
if (encoder && encoder.state === 'configured') {
|
|
134
|
+
console.log(`[MediaTransport] Key Frame Requested for ${track.kind}`);
|
|
135
|
+
// We can't force it here directly because we are in a read loop.
|
|
136
|
+
// But we can set a flag that the loop checks.
|
|
137
|
+
encoder.forceKeyFrame = true;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
console.warn(`[MediaTransport] Impossible to request KeyFrame: Encoder state is ${encoder?.state}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// Receives chunks and outputs a MediaStreamTrack
|
|
146
|
+
static receiveTrack(kind, onKeyFrameRequest) {
|
|
147
|
+
if ((typeof VideoDecoder === 'undefined') && (typeof AudioDecoder === 'undefined')) {
|
|
148
|
+
console.warn("WebCodecs API not supported.");
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const generator = new window.MediaStreamTrackGenerator({ kind });
|
|
152
|
+
const writer = generator.writable.getWriter();
|
|
153
|
+
let decoder = null;
|
|
154
|
+
// Throttling for PLI
|
|
155
|
+
let lastPli = 0;
|
|
156
|
+
let expectedSeq = null;
|
|
157
|
+
if (kind === 'video') {
|
|
158
|
+
decoder = new window.VideoDecoder({
|
|
159
|
+
output: (frame) => {
|
|
160
|
+
try {
|
|
161
|
+
writer.write(frame);
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
frame.close();
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
error: (e) => console.error("VideoDecoder error", e)
|
|
168
|
+
});
|
|
169
|
+
decoder.configure({
|
|
170
|
+
codec: 'vp8'
|
|
171
|
+
});
|
|
172
|
+
// Force wait for key frame immediately
|
|
173
|
+
decoder.waitingForKeyFrame = true;
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
decoder = new window.AudioDecoder({
|
|
177
|
+
output: (data) => {
|
|
178
|
+
writer.write(data);
|
|
179
|
+
data.close();
|
|
180
|
+
},
|
|
181
|
+
error: (e) => console.error("AudioDecoder error", e)
|
|
182
|
+
});
|
|
183
|
+
decoder.configure({
|
|
184
|
+
codec: 'opus',
|
|
185
|
+
sampleRate: 48000,
|
|
186
|
+
numberOfChannels: 1
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
// Attach a method to the track object or return a controller?
|
|
190
|
+
// We will just return the track, but we need a way to feed data into the decoder.
|
|
191
|
+
// We can monkey-patch the track or store it in a map in PeerConnection.
|
|
192
|
+
generator.feed = (data) => {
|
|
193
|
+
if (decoder.state === 'closed')
|
|
194
|
+
return;
|
|
195
|
+
// Verify Magic
|
|
196
|
+
if (data.byteLength < 1 || data[0] !== 0x77) {
|
|
197
|
+
console.error("[MediaTransport] Invalid magic byte in media stream! Dropping packet.");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (kind === 'video') {
|
|
201
|
+
// Parse
|
|
202
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
203
|
+
const timestamp = Number(view.getBigInt64(1, false));
|
|
204
|
+
const isKey = view.getUint8(9) === 1;
|
|
205
|
+
const seq = view.getUint16(10, false);
|
|
206
|
+
const chunkData = data.slice(12);
|
|
207
|
+
// Sequence Check
|
|
208
|
+
if (expectedSeq !== null) {
|
|
209
|
+
if (seq !== expectedSeq) {
|
|
210
|
+
console.warn(`[MediaTransport] Sequence Mismatch! Expected ${expectedSeq}, got ${seq}. Dropping and requesting PLI.`);
|
|
211
|
+
decoder.waitingForKeyFrame = true;
|
|
212
|
+
if (onKeyFrameRequest)
|
|
213
|
+
onKeyFrameRequest();
|
|
214
|
+
// We also reset expectedSeq to this new one + 1?
|
|
215
|
+
// No, we better wait for KeyFrame.
|
|
216
|
+
// But we must update expectedSeq to avoid infinite loops if the stream just continues.
|
|
217
|
+
expectedSeq = (seq + 1) % 65536;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
expectedSeq = (seq + 1) % 65536;
|
|
222
|
+
const chunk = new window.EncodedVideoChunk({
|
|
223
|
+
type: isKey ? 'key' : 'delta',
|
|
224
|
+
timestamp: timestamp,
|
|
225
|
+
data: chunkData
|
|
226
|
+
});
|
|
227
|
+
if (decoder.state === 'configured' && !isKey && decoder.waitingForKeyFrame !== false) {
|
|
228
|
+
// Drop delta frames until we get a keyframe
|
|
229
|
+
const now = Date.now();
|
|
230
|
+
if (now - lastPli > 1000) {
|
|
231
|
+
console.log("[MediaTransport] Dropping delta frame, waiting for key frame. Requesting PLI.");
|
|
232
|
+
if (onKeyFrameRequest)
|
|
233
|
+
onKeyFrameRequest();
|
|
234
|
+
lastPli = now;
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (isKey) {
|
|
239
|
+
decoder.waitingForKeyFrame = false;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
decoder.decode(chunk);
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
console.error("[MediaTransport] Decode error:", e);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
250
|
+
const timestamp = Number(view.getBigInt64(1, false));
|
|
251
|
+
const chunkData = data.slice(9);
|
|
252
|
+
const chunk = new window.EncodedAudioChunk({
|
|
253
|
+
type: 'key', // Audio usually all key frames or dependent? Opus is complicated.
|
|
254
|
+
timestamp: timestamp,
|
|
255
|
+
data: chunkData
|
|
256
|
+
});
|
|
257
|
+
decoder.decode(chunk);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
return generator;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Client } from '../core/Client';
|
|
2
|
+
import { Connection } from '../core/Connection';
|
|
3
|
+
export declare class PlutoPeerConnection extends EventTarget {
|
|
4
|
+
static defaultClient: Client | null;
|
|
5
|
+
static setDefaultClient(client: Client): void;
|
|
6
|
+
onicecandidate: ((ev: RTCPeerConnectionIceEvent) => any) | null;
|
|
7
|
+
oniceconnectionstatechange: ((ev: Event) => any) | null;
|
|
8
|
+
ontrack: ((ev: RTCTrackEvent) => any) | null;
|
|
9
|
+
onsignalingstatechange: ((ev: Event) => any) | null;
|
|
10
|
+
connectionState: RTCPeerConnectionState;
|
|
11
|
+
iceConnectionState: RTCIceConnectionState;
|
|
12
|
+
signalingState: RTCSignalingState;
|
|
13
|
+
localDescription: RTCSessionDescription | null;
|
|
14
|
+
remoteDescription: RTCSessionDescription | null;
|
|
15
|
+
private client;
|
|
16
|
+
private connection;
|
|
17
|
+
constructor(configuration?: RTCConfiguration, clientInstance?: Client);
|
|
18
|
+
createOffer(options?: RTCOfferOptions): Promise<RTCSessionDescriptionInit>;
|
|
19
|
+
createAnswer(options?: RTCAnswerOptions): Promise<RTCSessionDescriptionInit>;
|
|
20
|
+
setLocalDescription(desc: RTCSessionDescriptionInit): Promise<void>;
|
|
21
|
+
setRemoteDescription(desc: RTCSessionDescriptionInit): Promise<void>;
|
|
22
|
+
addIceCandidate(candidate: RTCIceCandidateInit | RTCIceCandidate): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Manually provide an existing connection (e.g. from an incoming connection listener)
|
|
25
|
+
*/
|
|
26
|
+
private bufferTracks;
|
|
27
|
+
handleIncomingConnection(connection: Connection): Promise<void>;
|
|
28
|
+
private receivers;
|
|
29
|
+
private setupHooks;
|
|
30
|
+
addTrack(track: MediaStreamTrack, ...streams: MediaStream[]): RTCRtpSender;
|
|
31
|
+
private mediaSenders;
|
|
32
|
+
private startIrohStream;
|
|
33
|
+
createDataChannel(label: string): RTCDataChannel;
|
|
34
|
+
sendData(data: any): void;
|
|
35
|
+
private connect;
|
|
36
|
+
close(): void;
|
|
37
|
+
private updateState;
|
|
38
|
+
}
|