From 21ff8b7ee956b5c639eec62b83c99fcf81586b53 Mon Sep 17 00:00:00 2001 From: Khalid Date: Sat, 9 Nov 2024 04:43:03 +0300 Subject: [PATCH] Janus-Jingle --- examples/janus-demo.html | 338 +++++++++++++++++++++++++++++++++++ package.json | 12 +- src/index.ts | 8 + src/jingle/MediaSession.ts | 76 +++++++- src/jingle/Session.ts | 4 +- src/jingle/SessionManager.ts | 4 + src/services/JanusService.ts | 254 ++++++++++++++++++++++++++ src/types/agent.ts | 13 ++ src/types/errors.ts | 13 ++ src/types/events.ts | 7 + src/types/janus.d.ts | 66 +++++++ tsconfig.json | 12 +- 12 files changed, 799 insertions(+), 8 deletions(-) create mode 100644 examples/janus-demo.html create mode 100644 src/services/JanusService.ts create mode 100644 src/types/agent.ts create mode 100644 src/types/errors.ts create mode 100644 src/types/events.ts create mode 100644 src/types/janus.d.ts diff --git a/examples/janus-demo.html b/examples/janus-demo.html new file mode 100644 index 00000000..b15108e4 --- /dev/null +++ b/examples/janus-demo.html @@ -0,0 +1,338 @@ + + + + + Janus Video Room Demo + + + + + + + +
+

Janus Video Room Demo

+ + +
+

Connection Settings

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + + + + + +
+
+

Local Video

+ +
+
+

Remote Videos

+
+
+
+
+ + + + \ No newline at end of file diff --git a/package.json b/package.json index 84b71d25..db848938 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "punycode": "^2.1.1", "sdp": "^3.0.2", "tslib": "^2.2.0", - "ws": "^8.2.2" + "ws": "^8.2.2", + "janus-gateway": "^1.1.5", + "events": "^3.3.0" }, "devDependencies": { "@types/jest": "^27.0.1", @@ -37,7 +39,9 @@ "typescript": "^4.2.4", "webpack": "^5.72.1", "webpack-bundle-analyzer": "^4.4.0", - "webpack-cli": "^4.5.0" + "webpack-cli": "^4.5.0", + "@types/events": "^3.0.0", + "@types/async": "^3.2.20" }, "homepage": "https://stanzajs.org", "jest": { @@ -86,6 +90,8 @@ "license-check": "npx license-checker --production --excludePrivatePackages --summary", "lint": "eslint .", "test": "jest", - "validate": "npm ls" + "validate": "npm ls", + "type-check": "tsc --noEmit", + "type-check:watch": "tsc --noEmit --watch" } } diff --git a/src/index.ts b/src/index.ts index 2f79e239..9dfc11b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -244,6 +244,14 @@ export interface AgentConfig { * A list of language codes acceptable to the user. */ acceptLanguages?: string[]; + + /** + * Janus WebRTC Gateway Configuration + */ + janus?: { + enabled: boolean; + url: string; + }; } export interface Transport { diff --git a/src/jingle/MediaSession.ts b/src/jingle/MediaSession.ts index 3bc73802..24b166ba 100644 --- a/src/jingle/MediaSession.ts +++ b/src/jingle/MediaSession.ts @@ -22,6 +22,8 @@ import ICESession, { ICESessionOpts } from './ICESession'; import { exportToSDP, importFromSDP } from './sdp/Intermediate'; import { convertIntermediateToRequest, convertRequestToIntermediate } from './sdp/Protocol'; import { ActionCallback } from './Session'; +import { JanusService } from '../services/JanusService'; +import { JanusError, MediaError } from '../types/errors'; function applyStreamsCompatibility(content: JingleContent) { const application = content.application as JingleRtpDescription; @@ -49,6 +51,8 @@ function applyStreamsCompatibility(content: JingleContent) { export interface MediaSessionOpts extends ICESessionOpts { stream?: MediaStream; + useJanus?: boolean; + janusUrl?: string; } export default class MediaSession extends ICESession { @@ -59,9 +63,17 @@ export default class MediaSession extends ICESession { private _ringing = false; + private janusService?: JanusService; + + protected localStream?: MediaStream; + constructor(opts: MediaSessionOpts) { super(opts); + if (opts.useJanus && opts.janusUrl) { + this.janusService = new JanusService(this.parent, opts.janusUrl); + } + this.pc.addEventListener('track', (e: RTCTrackEvent) => { this.onAddTrack(e.track, e.streams[0]); }); @@ -173,9 +185,12 @@ export default class MediaSession extends ICESession { } } - public end(reason: JingleReasonCondition | JingleReason = 'success', silent = false): void { - for (const receiver of this.pc.getReceivers()) { - this.onRemoveTrack(receiver.track); + public async end(reason: JingleReasonCondition | JingleReason = 'success', silent = false): Promise { + if (this.janusService) { + await this.janusService.cleanup(); + } + if (this.localStream) { + this.localStream.getTracks().forEach(track => track.stop()); } super.end(reason, silent); } @@ -256,6 +271,7 @@ export default class MediaSession extends ICESession { if (track.kind === 'video') { this.includesVideo = true; } + this.localStream = stream; return this.processLocal('addtrack', async () => { if (this.pc.addTrack) { this.pc.addTrack(track, stream); @@ -364,4 +380,58 @@ export default class MediaSession extends ICESession { } return cb(); } + + private handleJanusError(error: Error): void { + this._log('error', 'Janus error:', error); + if (error instanceof JanusError) { + this.emit('error', error); + } else { + this.emit('error', new JanusError(error.message)); + } + } + + public async startWithJanus( + roomId?: string, + displayName?: string + ): Promise { + if (!this.janusService) { + throw new Error('Janus not configured for this session'); + } + + try { + // Create or join room + const actualRoomId = roomId || await this.janusService.createVideoRoom({ + publishers: 6, + bitrate: 512000 + }); + + // Join the room + await this.janusService.joinRoom(actualRoomId, displayName || 'anonymous'); + + // Publish our stream if we have one + if (this.localStream) { + await this.janusService.publishStream(this.localStream); + } + + this.state = 'active'; + + } catch (err) { + this.handleJanusError(err); + this.end('failed-application', true); + } + } + + // Add method to subscribe to other participants + public async subscribeToParticipant(publisherId: string): Promise { + if (!this.janusService) { + throw new Error('Janus not configured for this session'); + } + + try { + const remoteStream = await this.janusService.subscribeToFeed(publisherId); + this.onAddTrack(remoteStream.getTracks()[0], remoteStream); + } catch (err) { + this._log('error', 'Could not subscribe to participant', err); + } + } } diff --git a/src/jingle/Session.ts b/src/jingle/Session.ts index f45b3002..998cd726 100644 --- a/src/jingle/Session.ts +++ b/src/jingle/Session.ts @@ -1,4 +1,5 @@ import { AsyncPriorityQueue, priorityQueue } from 'async'; +import { EventEmitter } from 'events'; import { JingleAction, @@ -44,7 +45,7 @@ const unsupportedInfo = { type: 'modify' }; -export default class JingleSession { +export default class JingleSession extends EventEmitter { public parent: SessionManager; public sid: string; public peerID: string; @@ -58,6 +59,7 @@ export default class JingleSession { private _connectionState: string; constructor(opts: SessionOpts) { + super(); this.parent = opts.parent; this.sid = opts.sid || uuid(); this.peerID = opts.peerID; diff --git a/src/jingle/SessionManager.ts b/src/jingle/SessionManager.ts index faf0705f..5ac736e7 100644 --- a/src/jingle/SessionManager.ts +++ b/src/jingle/SessionManager.ts @@ -432,4 +432,8 @@ export default class SessionManager extends EventEmitter { this.emit('log', level, message, ...args); this.emit('log:' + level, message, ...args); } + + public emit(event: string, ...args: any[]): boolean { + return super.emit(event, ...args); + } } diff --git a/src/services/JanusService.ts b/src/services/JanusService.ts new file mode 100644 index 00000000..eda31780 --- /dev/null +++ b/src/services/JanusService.ts @@ -0,0 +1,254 @@ +import { Agent } from '../'; +import { Janus, JanusInstance, PluginHandle } from 'janus-gateway'; +import { EventEmitter } from 'events'; +import { JanusError } from '../types/errors'; +import { JanusServiceEvents } from '../types/events'; +import StrictEventEmitter from '../lib/StrictEventEmitter'; + +export class JanusService extends (EventEmitter as { + new (): StrictEventEmitter; +}) { + private janus?: JanusInstance; + private agent: Agent; + private roomId: string | null = null; + private publisherId: string | null = null; + private pluginHandle?: PluginHandle; + + constructor(agent: Agent, janusUrl: string) { + super(); + this.agent = agent; + this.initJanus(janusUrl); + } + + private initJanus(janusUrl: string): void { + Janus.init({ + debug: true, + callback: () => { + this.janus = new Janus({ + server: janusUrl, + success: () => { + console.log('Janus initialized successfully'); + }, + error: (error: any) => { + console.error('Janus initialization failed:', error); + } + }); + } + }); + } + + public async createVideoRoom(roomConfig: { + room?: number, + description?: string, + publishers?: number, + bitrate?: number + }): Promise { + return new Promise((resolve, reject) => { + this.janus?.attach({ + plugin: 'janus.plugin.videoroom', + success: (pluginHandle: PluginHandle) => { + const create = { + request: 'create', + ...roomConfig + }; + + pluginHandle.send({ + message: create, + success: (response: any) => { + this.roomId = response.room; + resolve(this.roomId); + }, + error: reject + }); + }, + error: reject + }); + }); + } + + public async joinRoom(roomId: string, displayName: string): Promise { + return new Promise((resolve, reject) => { + this.janus?.attach({ + plugin: 'janus.plugin.videoroom', + success: (pluginHandle: PluginHandle) => { + const join = { + request: 'join', + room: roomId, + ptype: 'publisher', + display: displayName + }; + + pluginHandle.send({ + message: join, + success: (response: any) => { + this.publisherId = response.id; + resolve(); + }, + error: reject + }); + }, + error: reject + }); + }); + } + + public async publishStream(stream: MediaStream): Promise { + return new Promise((resolve, reject) => { + if (!this.publisherId || !this.roomId) { + reject(new Error('Must join room before publishing')); + return; + } + + this.janus?.attach({ + plugin: 'janus.plugin.videoroom', + success: (pluginHandle: PluginHandle) => { + pluginHandle.createOffer({ + media: { + audioRecv: false, + videoRecv: false, + audioSend: true, + videoSend: true + }, + stream: stream, + success: (jsep: any) => { + const publish = { + request: 'publish', + audio: true, + video: true + }; + + pluginHandle.send({ + message: publish, + jsep: jsep, + success: resolve, + error: reject + }); + }, + error: reject + }); + }, + error: reject + }); + }); + } + + public async subscribeToFeed(publisherId: string): Promise { + return new Promise((resolve, reject) => { + this.janus?.attach({ + plugin: 'janus.plugin.videoroom', + success: (pluginHandle: PluginHandle) => { + const subscribe = { + request: 'join', + room: this.roomId, + ptype: 'subscriber', + feed: publisherId + }; + + pluginHandle.send({ + message: subscribe, + success: (response: any) => { + // Handle the subscription response + pluginHandle.createAnswer({ + jsep: response.jsep, + media: { + audioSend: false, + videoSend: false + }, + success: (jsep: any) => { + const start = { request: 'start' }; + pluginHandle.send({ + message: start, + jsep: jsep + }); + } + }); + }, + error: reject + }); + }, + onremotestream: (stream: MediaStream) => { + resolve(stream); + }, + error: reject + }); + }); + } + + public async cleanup(): Promise { + return new Promise((resolve, reject) => { + if (this.pluginHandle) { + this.pluginHandle.send({ + message: { request: 'leave' }, + success: () => { + if (this.janus) { + this.janus.destroy({ + success: () => { + this.janus = undefined; + this.pluginHandle = undefined; + this.roomId = null; + this.publisherId = null; + resolve(); + }, + error: reject + }); + } else { + resolve(); + } + }, + error: reject + }); + } else if (this.janus) { + this.janus.destroy({ + success: () => { + this.janus = undefined; + resolve(); + }, + error: reject + }); + } else { + resolve(); + } + }); + } + + private handleParticipantEvent(event: any): void { + if (event.joining) { + this.emit('participant-joined', event.id, event.display); + } else if (event.leaving) { + this.emit('participant-left', event.id); + } + } + + private attachEventHandlers(pluginHandle: PluginHandle): void { + pluginHandle.on('message', (msg: any) => { + if (msg.videoroom === 'event') { + this.handleParticipantEvent(msg); + } + }); + + pluginHandle.on('error', (error: any) => { + this.emit('error', new JanusError(error.message, error.code)); + }); + } + + private handleError(error: any): Error { + if (error instanceof Error) { + return error; + } + + if (typeof error === 'string') { + return new JanusError(error); + } + + if (error.code) { + return new JanusError(error.message || 'Unknown Janus error', error.code); + } + + return new JanusError('Unknown error occurred'); + } + + private emitError(error: any): void { + const processedError = this.handleError(error); + this.emit('error', processedError); + } +} \ No newline at end of file diff --git a/src/types/agent.ts b/src/types/agent.ts new file mode 100644 index 00000000..a0d814c6 --- /dev/null +++ b/src/types/agent.ts @@ -0,0 +1,13 @@ +import { EventEmitter } from 'events'; +import { JanusService } from '../services/JanusService'; + +export interface Agent extends EventEmitter { + jid: string; + config: AgentConfig; + janus?: JanusService; + // ... rest of existing Agent interface properties + + // Add Janus-specific methods + createJanusSession(url: string): JanusService; + destroyJanusSession(): Promise; +} \ No newline at end of file diff --git a/src/types/errors.ts b/src/types/errors.ts new file mode 100644 index 00000000..c6b34a6e --- /dev/null +++ b/src/types/errors.ts @@ -0,0 +1,13 @@ +export class JanusError extends Error { + constructor(message: string, public code?: number) { + super(message); + this.name = 'JanusError'; + } +} + +export class MediaError extends Error { + constructor(message: string, public constraint?: string) { + super(message); + this.name = 'MediaError'; + } +} \ No newline at end of file diff --git a/src/types/events.ts b/src/types/events.ts new file mode 100644 index 00000000..00d1a8b8 --- /dev/null +++ b/src/types/events.ts @@ -0,0 +1,7 @@ +export interface JanusServiceEvents { + 'participant-joined': (participantId: string, displayName: string) => void; + 'participant-left': (participantId: string) => void; + 'stream-started': (stream: MediaStream) => void; + 'stream-stopped': (stream: MediaStream) => void; + 'error': (error: Error) => void; +} \ No newline at end of file diff --git a/src/types/janus.d.ts b/src/types/janus.d.ts new file mode 100644 index 00000000..d17309f4 --- /dev/null +++ b/src/types/janus.d.ts @@ -0,0 +1,66 @@ +declare module 'janus-gateway' { + export interface JanusInitOptions { + debug?: boolean | 'all' | string[]; + callback?: () => void; + dependencies?: string[]; + } + + export interface JanusOptions { + server: string | string[]; + iceServers?: RTCIceServer[]; + ipv6?: boolean; + withCredentials?: boolean; + max_poll_events?: number; + destroyOnUnload?: boolean; + token?: string; + apisecret?: string; + success?: () => void; + error?: (error: any) => void; + destroyed?: () => void; + } + + export interface JanusEvents { + 'participant-joined': (participantId: string, displayName: string) => void; + 'participant-left': (participantId: string) => void; + 'stream-started': (stream: MediaStream) => void; + 'stream-stopped': (stream: MediaStream) => void; + 'error': (error: Error) => void; + } + + export interface PluginHandle extends EventEmitter { + plugin: string; + id: string; + token?: string; + detached: boolean; + webrtcStuff: any; + createOffer(options: any): void; + createAnswer(options: any): void; + send(options: any): void; + on(event: E, listener: JanusEvents[E]): this; + emit(event: E, ...args: Parameters): boolean; + } + + export interface JanusInstance { + attach(options: { + plugin: string; + opaqueId?: string; + success?: (handle: PluginHandle) => void; + error?: (error: any) => void; + consentDialog?: (on: boolean) => void; + onmessage?: (msg: any, jsep: any) => void; + onlocalstream?: (stream: MediaStream) => void; + onremotestream?: (stream: MediaStream) => void; + ondata?: (data: any) => void; + ondataopen?: () => void; + oncleanup?: () => void; + ondetached?: () => void; + }): void; + destroy(options?: { success?: () => void; error?: (error: any) => void }): void; + } + + export const Janus: { + new (options: JanusOptions): JanusInstance; + init(options: JanusInitOptions): void; + isWebrtcSupported(): boolean; + }; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 262b0444..32906431 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,17 @@ "moduleResolution": "node", "outDir": "./dist/cjs", "strict": true, - "target": "es2018" + "target": "es2018", + "typeRoots": [ + "./node_modules/@types", + "./src/types" + ], + "types": [ + "node", + "events", + "async", + "janus-gateway" + ] }, "include": ["src", "typings"], "exclude": ["dist"]