├── CNAME ├── public ├── icon.png ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json ├── js │ ├── settings.js │ ├── stream.js │ ├── video-stream │ │ ├── websocket-relay.js │ │ └── stream-video.js │ ├── ipc-handlers.js │ └── windows.js ├── index.html └── electron.js ├── _config.yml ├── src ├── setupTests.js ├── App.test.js ├── reportWebVitals.js ├── index.css ├── index.js ├── App.css ├── Viewer.js ├── App.js ├── Selector.js ├── logo.svg ├── Control.js └── Settings.js ├── .gitignore ├── LICENSE ├── package.json ├── .github └── workflows │ └── package_release.yml └── README.md /CNAME: -------------------------------------------------------------------------------- 1 | framecast.app -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nathan-fiscaletti/framecast/HEAD/public/icon.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nathan-fiscaletti/framecast/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nathan-fiscaletti/framecast/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nathan-fiscaletti/framecast/HEAD/public/logo512.png -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | title: FrameCast 2 | description: Elevate video conferencing with versatile screen sharing 3 | remote_theme: pages-themes/midnight@v0.2.0 4 | show_downloads: false 5 | plugins: 6 | - jekyll-remote-theme # add this line to the plugins list if you already have one -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import App from './App'; 3 | 4 | test('renders learn react link', () => { 5 | render(); 6 | const linkElement = screen.getByText(/learn react/i); 7 | expect(linkElement).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | /dist 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | yarn.lock -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | padding: 0; 3 | margin: 0; 4 | } 5 | 6 | body { 7 | height:100%; 8 | overflow: hidden; 9 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 10 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 11 | sans-serif; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | code { 17 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 18 | monospace; 19 | } 20 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot(document.getElementById('root')); 8 | root.render( 9 | 10 | 11 | 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Viewer.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import JSMpeg from "@cycjimmy/jsmpeg-player"; 3 | 4 | const ffmpegIP = "127.0.0.1"; 5 | 6 | const Viewer = () => { 7 | useEffect(() => { 8 | var videoUrl = `ws://${ffmpegIP}:8082/`; 9 | var player = new JSMpeg.VideoElement("#video-container", videoUrl, { 10 | autoplay: true, 11 | canvas: '#video-canvas', 12 | }); 13 | console.log(player); 14 | }); 15 | 16 | return ( 17 |
24 | 28 |
29 | ); 30 | }; 31 | 32 | export default Viewer; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Nathan Fiscaletti 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import './App.css'; 2 | 3 | import React from 'react'; 4 | import Viewer from './Viewer'; 5 | import Selector from './Selector'; 6 | import Settings from './Settings'; 7 | import Control from './Control'; 8 | 9 | import '@fontsource/roboto/300.css'; 10 | import '@fontsource/roboto/400.css'; 11 | import '@fontsource/roboto/500.css'; 12 | import '@fontsource/roboto/700.css'; 13 | 14 | import { ThemeProvider, createTheme } from '@mui/material/styles'; 15 | import CssBaseline from '@mui/material/CssBaseline'; 16 | import green from '@mui/material/colors/green'; 17 | 18 | // Create the initial theme for the application. 19 | const theme = createTheme({ 20 | palette: { 21 | mode: 'dark', 22 | primary: green, 23 | }, 24 | }); 25 | 26 | function App() { 27 | // Get the content parameter from the URL. 28 | const urlParams = new URLSearchParams(window.location.search); 29 | const content = urlParams.get('content'); 30 | 31 | // Load the appropriate page based on the content parameter. 32 | return ( 33 | 34 | 35 | 36 | {(content === "viewer") && ()} 37 | {(content === "selector") && ()} 38 | {(content === "settings") && ()} 39 | {(content === "control") && ()} 40 | 41 | ); 42 | } 43 | 44 | export default App; 45 | -------------------------------------------------------------------------------- /public/js/settings.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const { v4: uuid } = require('uuid') 4 | 5 | const userDir = process.env.APPDATA || (process.platform === 'darwin' ? process.env.HOME + '/Library/Preferences' : process.env.HOME + "/.local/share"); 6 | const settingsDir = path.join(userDir, 'advanced-screen-streamer'); 7 | const settingsFile = path.join(settingsDir, 'settings.json'); 8 | 9 | const settings = { 10 | webSocketPort: 9065, 11 | streamPort: 9066, 12 | frameRate: 60, 13 | bitRate: 100000, 14 | previewVisible: false, 15 | showRegion: true, 16 | regionBorderSize: 4, 17 | enableAnalytics: true, 18 | systemInformationReportedAt: null, 19 | clientId: uuid(), 20 | defaultScreenCaptureWidth: 500, 21 | defaultScreenCaptureHeight: 500 22 | }; 23 | 24 | function initialize() { 25 | // Load settings 26 | try { 27 | if (!fs.existsSync(settingsDir)) { 28 | fs.mkdirSync(settingsDir); 29 | } 30 | 31 | if (!fs.existsSync(settingsFile)) { 32 | fs.writeFileSync(settingsFile, JSON.stringify(settings)); 33 | } 34 | 35 | const loadedSettings = JSON.parse(fs.readFileSync(settingsFile)); 36 | Object.assign(settings, loadedSettings); 37 | } catch (err) { 38 | console.error(err); 39 | } 40 | } 41 | 42 | function get() { 43 | return settings; 44 | } 45 | 46 | function set(newSettings) { 47 | Object.assign(settings, newSettings); 48 | fs.writeFileSync(settingsFile, JSON.stringify(settings)); 49 | } 50 | 51 | module.exports = { initialize, get, set }; -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 16 | 17 | 21 | 22 | 31 | 32 | 33 | 34 |
35 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/Selector.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Box, Button, Typography } from '@mui/material'; 4 | 5 | import CancelOutlinedIcon from '@mui/icons-material/CancelOutlined'; 6 | import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined'; 7 | 8 | const { ipcRenderer } = window.require('electron'); 9 | 10 | export default function Selector() { 11 | React.useEffect(() => { 12 | // Send an IPC message to restrict window size when the Selector is mounted 13 | ipcRenderer.send('selectRegionOpened', { maxWidth: 4095, maxHeight: 4095 }); 14 | }, []); 15 | 16 | return ( 17 | 32 | 33 | Resize / Position Window to Select Region 34 | 35 | 38 | 50 | 62 | 63 | 64 | ); 65 | } -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/js/stream.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | 3 | const { startWebsocketRelay } = require('./video-stream/websocket-relay'); 4 | const { startVideoStreamProcess } = require('./video-stream/stream-video'); 5 | 6 | const settings = require('./settings'); 7 | 8 | let streamScreen; 9 | let streamRegion = { 10 | x: 0, 11 | y: 0, 12 | width: settings.get().defaultScreenCaptureWidth, 13 | height: settings.get().defaultScreenCaptureHeight 14 | }; 15 | let streamProcess; 16 | let socketRelay; 17 | 18 | async function startStream({ app }) { 19 | const windows = require('./windows'); 20 | 21 | windows.createViewWindow({ app }); 22 | const streamSecret = crypto.randomBytes(20).toString('hex') 23 | socketRelay = await startWebsocketRelay({ 24 | streamSecret, 25 | streamPort: settings.get().streamPort, 26 | webSocketPort: settings.get().webSocketPort, 27 | }); 28 | streamProcess = await startVideoStreamProcess({ 29 | frameRate: settings.get().frameRate, 30 | bitRate: `${settings.get().bitRate}k`, 31 | streamPort: settings.get().streamPort, 32 | showRegion: settings.get().showRegion, 33 | streamSecret, 34 | offsetX: streamRegion.x, 35 | offsetY: streamRegion.y, 36 | width: Math.min(streamRegion.width, 4095), // Max resolution ffmpeg can handel 37 | height: Math.min(streamRegion.height, 4095), // Max resolution ffmpeg can handel 38 | screenId: streamScreen.id, 39 | scaleFactor: streamScreen.scaleFactor 40 | }); 41 | windows.getViewWindow().setResizable(true); 42 | windows.getViewWindow().setContentSize(streamRegion.width, streamRegion.height); 43 | windows.getViewWindow().setResizable(false); 44 | windows.getViewWindow().reload(); 45 | } 46 | 47 | function stopStream() { 48 | if (socketRelay) { 49 | socketRelay.close(); 50 | socketRelay = null; 51 | } 52 | 53 | if (streamProcess) { 54 | streamProcess.kill(); 55 | streamProcess = null; 56 | } 57 | 58 | const windows = require('./windows'); 59 | if (windows.getViewWindow() && !windows.getViewWindow().isDestroyed()) { 60 | windows.getViewWindow().setClosable(true); 61 | windows.closeViewWindow(); 62 | } 63 | } 64 | 65 | function isStreaming() { 66 | return !!streamProcess; 67 | } 68 | 69 | function getStreamScreen() { 70 | return streamScreen; 71 | } 72 | 73 | function getStreamRegion() { 74 | return streamRegion; 75 | } 76 | 77 | function setStreamScreen(screen) { 78 | streamScreen = screen; 79 | } 80 | 81 | function setStreamRegion(region) { 82 | streamRegion = region; 83 | } 84 | 85 | module.exports = { isStreaming, startStream, stopStream, getStreamRegion, getStreamScreen, setStreamRegion, setStreamScreen }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frame-cast", 3 | "version": "1.1.0", 4 | "private": true, 5 | "homepage": "./", 6 | "author": { 7 | "name": "Nathan Fiscaletti", 8 | "email": "nate.fiscaletti@gmail.com" 9 | }, 10 | "description": "An application for streaming a region of your screen.", 11 | "main": "./public/electron.js", 12 | "build": { 13 | "appId": "com.github.nathan_fiscaletti.advanced_screen_streamer", 14 | "productName": "FrameCast", 15 | "files": [ 16 | "build/**/**/*", 17 | "node_modules/**/**/*" 18 | ], 19 | "directories": { 20 | "buildResources": "public" 21 | }, 22 | "mac": { 23 | "target": "dmg" 24 | }, 25 | "win": { 26 | "target": "nsis" 27 | }, 28 | "linux": { 29 | "target": "deb", 30 | "category": "AudioVideo" 31 | } 32 | }, 33 | "dependencies": { 34 | "@cycjimmy/jsmpeg-player": "^6.0.5", 35 | "@emotion/react": "^11.10.6", 36 | "@emotion/styled": "^11.10.6", 37 | "@fontsource/roboto": "^4.5.8", 38 | "@mui/icons-material": "^5.11.16", 39 | "@mui/lab": "^5.0.0-alpha.127", 40 | "@mui/material": "^5.12.1", 41 | "@testing-library/jest-dom": "^5.16.5", 42 | "@testing-library/react": "^13.4.0", 43 | "@testing-library/user-event": "^13.5.0", 44 | "chalk": "v4", 45 | "ffmpeg-static": "^5.1.0", 46 | "posthog-node": "^3.1.1", 47 | "react": "^18.2.0", 48 | "react-dom": "^18.2.0", 49 | "react-scripts": "5.0.1", 50 | "uuid": "^9.0.0", 51 | "web-vitals": "^2.1.4", 52 | "ws": "^8.13.0" 53 | }, 54 | "scripts": { 55 | "start": "react-scripts start", 56 | "build": "react-scripts build", 57 | "test": "react-scripts test", 58 | "eject": "react-scripts eject", 59 | "electron:start": "concurrently -k \"cross-env BROWSER=none npm run start\" \"wait-on http://127.0.0.1:3000 && electronmon .\"", 60 | "electron:package:mac:x64": "npm run build && electron-builder -m --x64 -c.extraMetadata.main=build/electron.js", 61 | "electron:package:mac:arm64": "npm run build && electron-builder -m --arm64 -c.extraMetadata.main=build/electron.js", 62 | "electron:package:win": "npm run build && electron-builder -w -c.extraMetadata.main=build/electron.js", 63 | "electron:package:linux": "npm run build && electron-builder -l -c.extraMetadata.main=build/electron.js" 64 | }, 65 | "eslintConfig": { 66 | "extends": [ 67 | "react-app", 68 | "react-app/jest" 69 | ] 70 | }, 71 | "browserslist": { 72 | "production": [ 73 | "last 1 electron version" 74 | ], 75 | "development": [ 76 | "last 1 electron version" 77 | ] 78 | }, 79 | "devDependencies": { 80 | "concurrently": "^8.0.1", 81 | "cross-env": "^7.0.3", 82 | "depcheck": "^1.4.3", 83 | "electron": "^24.1.2", 84 | "electron-builder": "^23.6.0", 85 | "electronmon": "^2.0.2", 86 | "wait-on": "^7.0.1" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /public/js/video-stream/websocket-relay.js: -------------------------------------------------------------------------------- 1 | var http = require('http'), 2 | WebSocket = require('ws'); 3 | 4 | module.exports = { 5 | startWebsocketRelay: ({ 6 | streamSecret, 7 | streamPort = 8081, 8 | websocketPort = 8082, 9 | }) => { 10 | return new Promise((resolve, reject) => { 11 | // Websocket Server 12 | const socketServer = new WebSocket.Server({ port: websocketPort, perMessageDeflate: false }); 13 | socketServer.connectionCount = 0; 14 | socketServer.on('connection', function (socket, upgradeReq) { 15 | socketServer.connectionCount++; 16 | console.log( 17 | 'New WebSocket Connection: ', 18 | (upgradeReq || socket.upgradeReq).socket.remoteAddress, 19 | (upgradeReq || socket.upgradeReq).headers['user-agent'], 20 | '(' + socketServer.connectionCount + ' total)' 21 | ); 22 | socket.on('close', function (code, message) { 23 | socketServer.connectionCount--; 24 | console.log( 25 | 'Disconnected WebSocket (' + socketServer.connectionCount + ' total)' 26 | ); 27 | }); 28 | }); 29 | socketServer.broadcast = function (data) { 30 | socketServer.clients.forEach(function each(client) { 31 | if (client.readyState === WebSocket.OPEN) { 32 | client.send(data); 33 | } 34 | }); 35 | }; 36 | 37 | // HTTP Server to accept incomming MPEG-TS Stream from ffmpeg 38 | const streamServer = http.createServer(function (request, response) { 39 | var params = request.url.substr(1).split('/'); 40 | 41 | if (params[0] !== streamSecret) { 42 | console.log( 43 | 'Failed Stream Connection: ' + request.socket.remoteAddress + ':' + 44 | request.socket.remotePort + ' - wrong secret.' 45 | ); 46 | response.end(); 47 | } 48 | 49 | response.connection.setTimeout(0); 50 | console.log( 51 | 'Stream Connected: ' + 52 | request.socket.remoteAddress + ':' + 53 | request.socket.remotePort 54 | ); 55 | request.on('data', function (data) { 56 | socketServer.broadcast(data); 57 | if (request.socket.recording) { 58 | request.socket.recording.write(data); 59 | } 60 | }); 61 | request.on('end', function () { 62 | console.log('close'); 63 | if (request.socket.recording) { 64 | request.socket.recording.close(); 65 | } 66 | }); 67 | }) 68 | // Keep the socket open for streaming 69 | streamServer.headersTimeout = 0; 70 | 71 | streamServer.on("listening", function () { 72 | console.log('Listening for incoming MPEG-TS Stream on http://127.0.0.1:' + streamPort + '/'); 73 | console.log('Awaiting WebSocket connections on ws://127.0.0.1:' + websocketPort + '/'); 74 | resolve({ 75 | close: () => { 76 | socketServer.close(); 77 | streamServer.close(); 78 | } 79 | }); 80 | }); 81 | 82 | streamServer.listen(streamPort); 83 | }); 84 | } 85 | }; 86 | 87 | -------------------------------------------------------------------------------- /public/js/ipc-handlers.js: -------------------------------------------------------------------------------- 1 | const windows = require('./windows'); 2 | const settings = require('./settings'); 3 | const stream = require('./stream'); 4 | 5 | module.exports = { 6 | initialize: (app, ipcMain, screen) => { 7 | ipcMain.on('closeRegionSelector', () => { 8 | windows.closeRegionSelectionWindow(); 9 | }); 10 | 11 | ipcMain.on('saveRegion', () => { 12 | const bounds = windows.getRegionSelectionWindow().getBounds(); 13 | 14 | const streamScreen = screen.getDisplayNearestPoint( 15 | windows.getRegionSelectionWindow().getBounds() 16 | ) 17 | stream.setStreamScreen(streamScreen); 18 | 19 | // Limit capture window to screen size 20 | const width = Math.min(bounds.width, streamScreen.workAreaSize.width) 21 | const height = Math.min(bounds.height, streamScreen.workAreaSize.width) 22 | 23 | let streamRegion = { x: bounds.x, y: bounds.y, width: width, height: height }; 24 | if (process.platform === "darwin") { 25 | streamRegion.x -= stream.getStreamScreen().bounds.x; 26 | streamRegion.y -= stream.getStreamScreen().bounds.y; 27 | } 28 | stream.setStreamRegion(streamRegion); 29 | windows.closeRegionSelectionWindow(); 30 | 31 | if (stream.isStreaming()) { 32 | stream.stopStream(); 33 | stream.startStream({ app }); 34 | } 35 | }); 36 | 37 | ipcMain.on("getSettings", (event) => { 38 | event.sender.send("settings", settings.get()); 39 | }); 40 | 41 | ipcMain.on("closeSettingsWindow", (event) => { 42 | windows.closeSettingsWindow(); 43 | }); 44 | 45 | ipcMain.on("updateSettings", (event, newSettings) => { 46 | const oldSettings = settings.get() 47 | // If default region changed, update current region to match 48 | if (oldSettings.defaultScreenCaptureWidth !== newSettings.defaultScreenCaptureWidth || 49 | oldSettings.defaultScreenCaptureHeight !== newSettings.defaultScreenCaptureHeight) { 50 | const streamRegion = stream.getStreamRegion() 51 | streamRegion.width = newSettings.defaultScreenCaptureWidth 52 | streamRegion.height = newSettings.defaultScreenCaptureHeight 53 | stream.setStreamRegion(streamRegion) 54 | } 55 | 56 | settings.set(newSettings); 57 | if (stream.isStreaming()) { 58 | stream.stopStream(); 59 | stream.startStream({ app }); 60 | } 61 | }); 62 | 63 | ipcMain.on('selectRegionOpened', (event, { maxWidth, maxHeight }) => { 64 | windows.restrictRegionSelectionWindowSize(maxWidth, maxHeight); 65 | }); 66 | 67 | ipcMain.on("selectRegion", (event) => { 68 | windows.createRegionSelectionWindow({ app }); 69 | }); 70 | 71 | ipcMain.on("showSettings", (event, props) => { 72 | let _props = { app, tab: 0 }; 73 | if (props) { 74 | _props = { ..._props, ...props }; 75 | } 76 | windows.createSettingsWindow(_props); 77 | }); 78 | 79 | ipcMain.on("startStream", (event) => { 80 | stream.startStream({ app }); 81 | }); 82 | 83 | ipcMain.on("stopStream", (event) => { 84 | stream.stopStream(); 85 | }); 86 | } 87 | }; -------------------------------------------------------------------------------- /public/electron.js: -------------------------------------------------------------------------------- 1 | const settings = require('./js/settings'); 2 | // Initialize the application settings by loading them from disk. 3 | settings.initialize(); 4 | 5 | const { PostHog } = require('posthog-node'); 6 | 7 | // Module to control the application lifecycle and the native browser window. 8 | const { app, BrowserWindow, ipcMain, screen, protocol } = require("electron"); 9 | 10 | const windows = require('./js/windows'); 11 | const ipcHandlers = require('./js/ipc-handlers'); 12 | 13 | 14 | // Initialize the IPC handlers used to control the main process from 15 | // the renderer process. 16 | ipcHandlers.initialize(app, ipcMain, screen); 17 | 18 | // This is required to get transparency working on linux. 19 | if (process.platform === 'linux') { 20 | app.commandLine.appendSwitch('enable-transparent-visuals'); 21 | app.disableHardwareAcceleration(); 22 | } 23 | 24 | // Create the initial control window once the application is ready. 25 | app.whenReady().then(async () => { 26 | // Report monitor count, size, and display scaling factor. 27 | // set the analytics expiry to 14 days 28 | const NOW = new Date().getTime(); 29 | const ANALYTICS_EXPIRY = 1000 * 60 * 60 * 24 * 14; 30 | if (settings.get().enableAnalytics && ( 31 | settings.get().systemInformationReportedAt === null || 32 | NOW - settings.get().systemInformationReportedAt > ANALYTICS_EXPIRY 33 | )) { 34 | (async () => { 35 | const client = new PostHog( 36 | 'phc_q6GSeEJBJTIIlAlLwRxWxA8OvVNS2sn32mAEWcJZyZD', 37 | { host: 'https://app.posthog.com' } 38 | ); 39 | 40 | const displays = screen.getAllDisplays(); 41 | const displayInfo = displays.map((display, i) => { 42 | return { 43 | id: display.id, 44 | bounds: display.bounds, 45 | workArea: display.workArea, 46 | scaleFactor: display.scaleFactor 47 | }; 48 | }); 49 | 50 | client.capture({ 51 | distinctId: settings.get().clientId, 52 | event: 'screen-information', 53 | properties: { 54 | version: app.getVersion(), 55 | platform: process.platform, 56 | platformVersion: process.getSystemVersion(), 57 | displays: displayInfo 58 | } 59 | }); 60 | 61 | settings.set({ 62 | ...settings.get(), 63 | systemInformationReportedAt: NOW, 64 | }); 65 | 66 | await client.shutdownAsync(); 67 | })(); 68 | } 69 | 70 | windows.createControlWindow({ app, screen }); 71 | 72 | // On macOS it's common to re-create a window in the app when the 73 | // dock icon is clicked and there are no other windows open. 74 | app.on("activate", function () { 75 | if (BrowserWindow.getAllWindows().length === 0) { 76 | windows.createControlWindow({ app, screen }); 77 | } 78 | }); 79 | }); 80 | 81 | // Quit when all windows are closed, except on macOS. 82 | app.on("window-all-closed", function () { 83 | if (process.platform !== "darwin") { 84 | app.quit(); 85 | } 86 | }); 87 | 88 | // Disable navigation in application windows, this is to prevent 89 | // the user from navigating to a page that is not part of the application. 90 | app.on("web-contents-created", (event, contents) => { 91 | contents.on("will-navigate", (event, navigationUrl) => { 92 | event.preventDefault(); 93 | }); 94 | }); -------------------------------------------------------------------------------- /.github/workflows/package_release.yml: -------------------------------------------------------------------------------- 1 | name: Package and Deploy 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build-and-deploy-linux: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | # Set up Node.js 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: '18.x' 16 | # Install dependencies 17 | - name: Install Dependencies 18 | run: npm install 19 | # Build the project 20 | - name: Package for Linux 21 | run: npm run electron:package:linux 22 | # Rename the release artifact 23 | - name: Rename Artifact 24 | run: mv dist/frame-cast_*.deb dist/FrameCast.Setup.linux_amd64.deb 25 | # Upload the release artifact 26 | - name: Upload Artifacts 27 | uses: softprops/action-gh-release@v2 28 | with: 29 | files: | 30 | dist/FrameCast.Setup.linux_amd64.deb 31 | 32 | build-and-deploy-windows: 33 | runs-on: windows-latest 34 | steps: 35 | - uses: actions/checkout@v2 36 | # Set up Node.js 37 | - uses: actions/setup-node@v1 38 | with: 39 | node-version: '18.x' 40 | # Install dependencies 41 | - name: Install Dependencies 42 | run: npm install 43 | # Build the project 44 | - name: Package for Windows 45 | run: npm run electron:package:win 46 | env: 47 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 48 | # Rename artifacts 49 | - name: Rename Artifact Executable 50 | run: mv "dist/FrameCast Setup *.exe" "dist/FrameCast.Setup.windows_amd64.exe" 51 | - name: Rename Artifact Executable Blockmap 52 | run: mv "dist/FrameCast Setup *.exe.blockmap" "dist/FrameCast.Setup.windows_amd64.exe.blockmap" 53 | # Upload the release artifact 54 | - name: Upload Artifacts 55 | uses: softprops/action-gh-release@v2 56 | with: 57 | files: | 58 | dist/FrameCast.Setup.windows_amd64.exe 59 | dist/FrameCast.Setup.windows_amd64.exe.blockmap 60 | 61 | build-and-deploy-mac: 62 | runs-on: macos-latest 63 | steps: 64 | - uses: actions/checkout@v2 65 | # Set up Node.js 66 | - uses: actions/setup-node@v1 67 | with: 68 | node-version: '18.x' 69 | # Install dependencies 70 | - name: Install Dependencies 71 | run: npm install 72 | # Build the project 73 | - name: Package for MacOS (x64) 74 | run: npm run electron:package:mac:x64 75 | env: 76 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 77 | # Rename the release artifact 78 | - name: Rename Artifact 79 | run: mv dist/FrameCast-*.dmg dist/FrameCast.Setup.darwin_x64.dmg 80 | - name: Rename Artifact Blockmap 81 | run: mv dist/FrameCast-*.dmg.blockmap dist/FrameCast.Setup.darwin_x64.dmg.blockmap 82 | # Build the project 83 | - name: Package for MacOS (arm64) 84 | run: npm run electron:package:mac:arm64 85 | env: 86 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 87 | # Rename the release artifact 88 | - name: Rename Artifact 89 | run: mv dist/FrameCast-*.dmg dist/FrameCast.Setup.darwin_arm64.dmg 90 | - name: Rename Artifact Blockmap 91 | run: mv dist/FrameCast-*.dmg.blockmap dist/FrameCast.Setup.darwin_arm64.dmg.blockmap 92 | # Upload the release artifact 93 | - name: Upload Artifacts 94 | uses: softprops/action-gh-release@v2 95 | with: 96 | files: | 97 | dist/FrameCast.Setup.darwin_x64.dmg 98 | dist/FrameCast.Setup.darwin_x64.dmg.blockmap 99 | dist/FrameCast.Setup.darwin_arm64.dmg 100 | dist/FrameCast.Setup.darwin_arm64.dmg.blockmap -------------------------------------------------------------------------------- /src/Control.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Box, Button, Typography, Tooltip, Paper, Link } from '@mui/material'; 4 | 5 | import CopyrightIcon from '@mui/icons-material/Copyright'; 6 | import StopIcon from '@mui/icons-material/Stop'; 7 | import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'; 8 | import SettingsIcon from '@mui/icons-material/Settings'; 9 | import PhotoSizeSelectSmallIcon from '@mui/icons-material/PhotoSizeSelectSmall'; 10 | 11 | const { ipcRenderer } = window.require('electron'); 12 | 13 | export default function Control() { 14 | const [streaming, setStreaming] = React.useState(false); 15 | 16 | const startStream = () => { 17 | setStreaming(true); 18 | ipcRenderer.send("startStream"); 19 | }; 20 | 21 | const stopStream = () => { 22 | setStreaming(false); 23 | ipcRenderer.send("stopStream"); 24 | }; 25 | 26 | return ( 27 | 28 | 29 | 30 | {!streaming && ( 31 | 32 | 238 | 239 | 240 | 241 | {selectedTab === 0 && ( 242 | 247 | setSaveFinished(false)}> 248 | setSaveFinished(false)} 251 | variant="filled" 252 | sx={{ width: '100%', mb: 4, ml: 3, mr: 3 }} 253 | > 254 | {error ? "Error" : 'Settings Saved!'} 255 | {error ? error.message : "The settings have been saved successfully."} 256 | 257 | 258 | 259 | 260 | 261 | Application Settings 262 | 263 | 264 | 265 | These settings are used to adjust how the screen capture software communicates with the application. The default values should work for most users. 266 | 267 | 268 | setWebSocketPort(e.target.value)} InputProps={{ min: 255, max: 65535 }} /> 269 | setStreamPort(e.target.value)} InputProps={{ min: 255, max: 65535 }} /> 270 | 271 | 272 | 273 | 274 | Stream Settings 275 | 276 | 277 | 278 | These settings are used to adjust the invocation of the screen capture process. The default values should work for most users. 279 | 280 | 281 | setDefaultScreenCaptureWidth(parseInt(e.target.value))} InputProps={{ min: 100, max: 4095 }} /> 282 | setDefaultScreenCaptureHeight(parseInt(e.target.value))} InputProps={{ min: 100, max: 4095 }} /> 283 | 284 | 285 | 286 | 287 | Screen Capture Backend 288 | 297 | 298 | 299 | 300 | Show a preview of the selected region while streaming. 301 | 302 | 303 | 304 | setPreviewVisible(checked)} /> 305 | 306 | 307 | 308 | 309 | Frame Rate 310 | 311 | 312 | setFrameRate(newValue)} 319 | /> 320 | setFrameRate(e.target.value)} 328 | sx={{ minWidth: "155px" }} 329 | onBlur={() => { if (frameRate < 15) setFrameRate(15); else if (frameRate > 240) setFrameRate(240); }} 330 | InputProps={{ 331 | min: 15, 332 | max: 240, 333 | endAdornment: fps 334 | }} 335 | /> 336 | 337 | 338 | 339 | 340 | Bit Rate 341 | 342 | 343 | setBitRate(newValue)} 350 | /> 351 | setBitRate(e.target.value)} 359 | sx={{ minWidth: "155px" }} 360 | onBlur={() => { if (bitRate < 1000) setFrameRate(1000); else if (bitRate > 1000000) setFrameRate(1000000); }} 361 | InputProps={{ 362 | min: 1000, 363 | max: 1000000, 364 | endAdornment: kbps 365 | }} 366 | /> 367 | 368 | 369 | 370 | {(platform === 'win32' || platform === 'linux') && (<> 371 | Region Visibility 372 | 373 | 374 | Show a border around the selected region while streaming.{platform === 'linux' && (<>
(This feature is only supported on XCB-based x11grab))} 375 |
376 | 377 | 378 | setShowRegion(checked)} /> 379 | 380 |
381 | )} 382 | 383 | {platform === 'linux' && ( 384 | 385 | 386 | 387 | 388 | setRegionBorderSize(newValue)} 395 | /> 396 | setRegionBorderSize(e.target.value)} 405 | sx={{ minWidth: "155px" }} 406 | onBlur={() => { if (regionBorderSize < 1) setRegionBorderSize(1); else if (regionBorderSize > 32) setFrameRate(32); }} 407 | InputProps={{ 408 | min: 15, 409 | max: 240, 410 | endAdornment: px 411 | }} 412 | /> 413 | 414 | 415 | 416 | 417 | )} 418 |
419 |
420 | )} 421 | 422 | {selectedTab === 1 && ( 423 | 428 | logo 429 | 430 | setShowUpToDate(false)} 433 | variant="filled" 434 | sx={{ width: '100%', mb: 2 }} 435 | > 436 | {error ? "Error" : 'Up to date!'} 437 | {error ? error.message : "You are running the latest version of FrameCast."} 438 | 439 | 440 | 441 | 442 | setUpdateError(null)} 445 | variant="filled" 446 | sx={{ width: '100%', mb: 2 }} 447 | > 448 | Error 449 | {updateError && updateError.message} 450 | 451 | 452 | 453 | 454 | 455 | FrameCast 456 | 457 | 458 | 459 | 460 | 461 | Version {version} 462 | 463 | 464 | 465 | 466 | 467 | Created by shell.openExternal("https://github.com/nathan-fiscaletti")}>Nathan Fiscaletti 468 | 469 | 470 | 471 | 472 | 473 | Licensed under the shell.openExternal("https://en.wikipedia.org/wiki/MIT_License")}>MIT License 474 | 475 | 476 | 477 | 478 | 479 | nathan-fiscaletti/framecast ( shell.openExternal("https://github.com/nathan-fiscaletti/framecast")}>View on Github) 480 | 481 | 482 | checkForUpdate()} 487 | sx={{ mt: 2 }} 488 | fullWidth 489 | > 490 | Check for Update 491 | 492 | 493 | 494 | 495 | 496 | Analytics 497 | 498 | 499 | 500 | 501 | FrameCast periodically collects information about your system such as display resolution, operating system and operating system version in order to improve the application. 502 | 503 | 504 | 505 | Share system information with FrameCast 506 | 507 | 508 | 509 | setEnableAnalytics(checked)} /> 510 | 511 | 512 | 513 | FrameCast does not collect any personally identifiable information. 514 | 515 | copyTrackingId()} 519 | sx={{ mt: 1 }} 520 | fullWidth 521 | > 522 | Copy Tracking ID 523 | 524 | 525 | 526 | 527 | 528 | 529 | Attribution 530 | 531 | 532 | 533 | @cycjimmy/jsmpeg-player ( shell.openExternal("https://github.com/cycjimmy/jsmpeg-player")}>View on Github) 534 | 535 | 536 | MaterialUI ( shell.openExternal("https://mui.com/")}>Open Website) 537 | 538 | 539 | 540 | )} 541 | ); 542 | } --------------------------------------------------------------------------------