diff --git a/package-lock.json b/package-lock.json index 40196950..e1f905fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.6.1", "hasInstallScript": true, "dependencies": { + "@cycjimmy/jsmpeg-player": "^6.0.5", "@reduxjs/toolkit": "^1.8.0", "@xstate/react": "^1.6.3", "chalk": "^5.0.1", @@ -17,8 +18,10 @@ "date-fns": "^2.28.0", "electron-log": "^4.4.6", "execa": "^6.1.0", + "jsmpeg": "^1.0.0", "lodash": "^4.17.21", "nanoid": "^3.3.1", + "node-rtsp-stream": "^0.0.9", "polished": "^4.1.4", "qr-scanner": "^1.4.1", "react": "^17.0.2", @@ -1344,6 +1347,11 @@ "node": ">=12" } }, + "node_modules/@cycjimmy/jsmpeg-player": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.0.5.tgz", + "integrity": "sha512-bVNHQ7VN9ecKT5AI/6RC7zpW/y4ca68a9txeR5Wiin+jKpUn/7buMe+5NPub89A8NNeNnKPQfrD2+c76ch36mA==" + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -10826,6 +10834,11 @@ "node": ">=4" } }, + "node_modules/jsmpeg": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/jsmpeg/-/jsmpeg-1.0.0.tgz", + "integrity": "sha512-wlBKWVJ93NRJaCfrJ1KAgpMvZBLzpZxH3wnC1Yj7DudMDa/5hHeL1HfvW48ndR8GlI4irrqCXuOGhgayP9EbHw==" + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -11659,6 +11672,14 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.2.tgz", "integrity": "sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==" }, + "node_modules/node-rtsp-stream": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/node-rtsp-stream/-/node-rtsp-stream-0.0.9.tgz", + "integrity": "sha512-ynSkdHL4fuhctl1GeK890De7n8Dw+37D6IAZGrzsFSrd4TYho6neFQpMS1t0ZRDGsAegKh2p6kl1l9Vo3pJk8w==", + "dependencies": { + "ws": "^7.0.0" + } + }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -15580,7 +15601,6 @@ "version": "7.5.5", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz", "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==", - "dev": true, "engines": { "node": ">=8.3.0" }, @@ -16693,6 +16713,11 @@ "@cspotcode/source-map-consumer": "0.8.0" } }, + "@cycjimmy/jsmpeg-player": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.0.5.tgz", + "integrity": "sha512-bVNHQ7VN9ecKT5AI/6RC7zpW/y4ca68a9txeR5Wiin+jKpUn/7buMe+5NPub89A8NNeNnKPQfrD2+c76ch36mA==" + }, "@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -24004,6 +24029,11 @@ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==" }, + "jsmpeg": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/jsmpeg/-/jsmpeg-1.0.0.tgz", + "integrity": "sha512-wlBKWVJ93NRJaCfrJ1KAgpMvZBLzpZxH3wnC1Yj7DudMDa/5hHeL1HfvW48ndR8GlI4irrqCXuOGhgayP9EbHw==" + }, "json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -24657,6 +24687,14 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.2.tgz", "integrity": "sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==" }, + "node-rtsp-stream": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/node-rtsp-stream/-/node-rtsp-stream-0.0.9.tgz", + "integrity": "sha512-ynSkdHL4fuhctl1GeK890De7n8Dw+37D6IAZGrzsFSrd4TYho6neFQpMS1t0ZRDGsAegKh2p6kl1l9Vo3pJk8w==", + "requires": { + "ws": "^7.0.0" + } + }, "nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -27626,7 +27664,6 @@ "version": "7.5.5", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz", "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==", - "dev": true, "requires": {} }, "xml-name-validator": { diff --git a/package.json b/package.json index de490af0..fc055448 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "extends": "electron-snowpack/config/electron-builder.js" }, "dependencies": { + "@cycjimmy/jsmpeg-player": "^6.0.5", "@reduxjs/toolkit": "^1.8.0", "@xstate/react": "^1.6.3", "chalk": "^5.0.1", @@ -53,8 +54,10 @@ "date-fns": "^2.28.0", "electron-log": "^4.4.6", "execa": "^6.1.0", + "jsmpeg": "^1.0.0", "lodash": "^4.17.21", "nanoid": "^3.3.1", + "node-rtsp-stream": "^0.0.9", "polished": "^4.1.4", "qr-scanner": "^1.4.1", "react": "^17.0.2", diff --git a/src/main/RtspServer/script.ts b/src/main/RtspServer/script.ts new file mode 100644 index 00000000..f6157fb6 --- /dev/null +++ b/src/main/RtspServer/script.ts @@ -0,0 +1,12 @@ +import RTSPServer from 'node-rtsp-stream'; + +new RTSPServer.Stream({ + name: 'name', + streamUrl: 'rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mp4', + wsPort: 9999, + ffmpegOptions: { + // options ffmpeg flags + '-stats': '', // an option with no neccessary value uses a blank string + '-r': 30, // options with required values specify the value after the key + }, +}); diff --git a/src/main/index.ts b/src/main/index.ts index 0ad30773..7035c089 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3 +1,4 @@ +import '@/main/rtspServer'; import '@/main/audio'; import { log } from '@/main/logger'; import { APP_INFO_QUERY, APP_INFO_TYPE, AUDIO_STOP } from '@/main/preload'; diff --git a/src/main/preload.ts b/src/main/preload.ts index 97e5e72a..d6212c97 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -18,6 +18,9 @@ export type AUDIO_MSG_TYPE = { stdout: string; }; +export const RTSP_START = 'rtsp_start'; +export const RTSP_STOP = 'rtsp_stop'; + if (contextBridge) { // This is done this way because otherwise tests would try to import preload and it wouldn't work // It's possible the issue is mostly in the import order. diff --git a/src/main/preload/api.ts b/src/main/preload/api.ts index 06af67fb..f8237e5b 100644 --- a/src/main/preload/api.ts +++ b/src/main/preload/api.ts @@ -7,6 +7,8 @@ import { AUDIO_STOP, LOG_MSG, LOG_MSG_TYPE, + RTSP_START, + RTSP_STOP, } from '@/main/preload'; import { ipcRenderer } from 'electron'; @@ -29,4 +31,8 @@ export const preload = { ipcRenderer.on(AUDIO_MSG, (_event, args) => cb(args as AUDIO_MSG_TYPE)); }, }, + rtsp: { + start: (url: string) => ipcRenderer.invoke(RTSP_START, url), + stop: () => ipcRenderer.send(RTSP_STOP), + }, }; diff --git a/src/main/rtspServer.ts b/src/main/rtspServer.ts new file mode 100644 index 00000000..1f527ac8 --- /dev/null +++ b/src/main/rtspServer.ts @@ -0,0 +1,34 @@ +import { log } from '@/main/logger'; +import { ipcMain } from 'electron'; +import { execaNode, ExecaChildProcess } from 'execa'; + +interface RtspProcess { + process: ExecaChildProcess; + wsPort: number; +} + +const rtspServers: Map = new Map(); +let nextPort = 9000; + +ipcMain.handle('rtsp_start', (event, url: string) => { + const process = execaNode('script.ts'); + + log.info('starting rtsp process'); + rtspServers.set(url, { + process, + wsPort: nextPort, + }); + + return nextPort++; +}); + +ipcMain.on('rtsp_stop', (event, url: string) => { + const rtspProcess = rtspServers.get(url); + + if (!rtspProcess) { + return; + } + + rtspProcess.process.kill(); + rtspServers.delete(url); +}); diff --git a/src/renderer/components/Feed/Feeds/CameraFeed.tsx b/src/renderer/components/Feed/Feeds/CameraFeed.tsx index 4eaabe0b..d3b999a8 100644 --- a/src/renderer/components/Feed/Feeds/CameraFeed.tsx +++ b/src/renderer/components/Feed/Feeds/CameraFeed.tsx @@ -9,6 +9,7 @@ import * as React from 'react'; import { FC, useEffect, useRef, useState } from 'react'; import { log } from '@/renderer/logger'; import { QRFeed } from './QRFeed/QRFeed'; +import { RTSPFeed } from './RTSPFeed/RTSPFeed'; interface Props { feed: ICameraFeed; @@ -145,6 +146,8 @@ const View: FC = ({ feed }) => { /> ); + case CameraType.RTSP: + return ; default: return ; } @@ -153,7 +156,7 @@ const View: FC = ({ feed }) => { export const CameraFeed: FC = ({ feed }) => { const [state] = useActor(rosService); const connected = - state.matches('connected') || feed.camera.type === CameraType.WEBCAM; + !state.matches('connected') || feed.camera.type === CameraType.WEBCAM; useEffect(() => { log.debug('mounting camera', feed.camera.name); return () => { diff --git a/src/renderer/components/Feed/Feeds/RTSPFeed/RTSPFeed.tsx b/src/renderer/components/Feed/Feeds/RTSPFeed/RTSPFeed.tsx new file mode 100644 index 00000000..f17608ac --- /dev/null +++ b/src/renderer/components/Feed/Feeds/RTSPFeed/RTSPFeed.tsx @@ -0,0 +1,32 @@ +import React, { useEffect, useRef } from 'react'; +import JSMpeg from '@cycjimmy/jsmpeg-player'; +import { log } from '@/renderer/logger'; + +export const RTSPFeed = () => { + // Execute rtspServer node script with stream url as argument + const videoRef = useRef(null); + useEffect(() => { + const startServer = async () => { + const port = (await window.preloadApi.rtsp.start('rtsp://')) as number; + log.info(`RTSP server started on port ${port}`); + }; + startServer().catch((e) => log.error(e)); + }, []); + + // Create video element + useEffect(() => { + const videoWrapper = videoRef.current; + if (videoWrapper) { + new JSMpeg.VideoElement(videoWrapper, 'ws://localhost:9999', { + autoplay: true, + audio: false, + }); + } + }, [videoRef]); + + return ( + <> +
+ + ); +}; diff --git a/src/renderer/components/Feed/Feeds/RTSPFeed/index.tsx b/src/renderer/components/Feed/Feeds/RTSPFeed/index.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/renderer/store/modules/feed.ts b/src/renderer/store/modules/feed.ts index a23ca883..2b996d83 100644 --- a/src/renderer/store/modules/feed.ts +++ b/src/renderer/store/modules/feed.ts @@ -31,6 +31,7 @@ export enum CameraType { VP8 = 'vp8', WEBCAM = 'webcam', QR_CODE = 'qr_code', + RTSP = 'rtsp', } export type FeedType = diff --git a/src/renderer/types/jsmpeg.d.ts b/src/renderer/types/jsmpeg.d.ts new file mode 100644 index 00000000..5dfae4a9 --- /dev/null +++ b/src/renderer/types/jsmpeg.d.ts @@ -0,0 +1,13 @@ +declare module '@cycjimmy/jsmpeg-player' { + class VideoElement { + constructor( + videoWrapper: HTMLDivElement, + url: string, + options: { + canvas?: HTMLCanvasElement; + autoplay?: boolean; + audio?: boolean; + } + ); + } +} diff --git a/src/renderer/types/nodeRtspStream.d.ts b/src/renderer/types/nodeRtspStream.d.ts new file mode 100644 index 00000000..b5a4e5e3 --- /dev/null +++ b/src/renderer/types/nodeRtspStream.d.ts @@ -0,0 +1,13 @@ +declare module 'node-rtsp-stream' { + class Stream { + constructor(params: { + name: string; + streamUrl: string; + wsPort: number; + ffmpegOptions: { + '-stats': string; + '-r': number; + }; + }); + } +}