├── 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 |
42 | )}
43 | {streaming && (
44 |
45 |
55 | )}
56 |
57 |
59 |
60 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | Copyright {new Date().getFullYear()},
69 | Licensed under the MIT License
70 |
71 |
72 |
73 | ipcRenderer.send("showSettings", { tab: 1 })}>About
74 |
75 |
76 |
77 |
78 | );
79 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | https://github.com/user-attachments/assets/a48472d7-d03e-4da9-a17a-e75bdbf4c8b1
2 |
3 | # [FrameCast](https://framecast.app/)
4 |
5 | FrameCast is a dynamic screen sharing tool designed to enhance your video conferencing experience. It leverages the power of FFMPEG to allow users to select a specific region of any monitor and share just that region. This feature is particularly useful for those who work from home and need to share detailed visual content without losing clarity on standard video conference displays.
6 |
7 | ## Downloads
8 |
9 | - [Download for Windows (x64)](https://github.com/nathan-fiscaletti/framecast/releases/latest/download/FrameCast.Setup.windows_amd64.exe)
10 | - [Download for Linux (x64)](https://github.com/nathan-fiscaletti/framecast/releases/latest/download/FrameCast.Setup.linux_amd64.deb)
11 | - [Download for macOS (x64)](https://github.com/nathan-fiscaletti/framecast/releases/latest/download/FrameCast.Setup.darwin_x64.dmg)
12 | - [Download for macOS (arm64)](https://github.com/nathan-fiscaletti/framecast/releases/latest/download/FrameCast.Setup.darwin_arm64.dmg)
13 |
14 |
15 | ## Key Features
16 |
17 | - **Selective Screen Sharing**: Choose exactly what part of your screen you want to share with participants.
18 | - **FFMPEG Backend**: Robust and high-performance media handling for smooth streaming quality.
19 | - **Customizable Stream Settings**: Adjust the bitrate and frame rate to optimize the quality and performance of your video streams.
20 | - **Support for Ultra-wide Displays**: Perfect for ultra-wide monitor users, ensuring that the shared content fits well on standard displays without distortion or unnecessary scaling.
21 | - **Simple and Intuitive Interface**: Easy to use and navigate, with straightforward controls for selecting and sharing your screen region.
22 |
23 | ## Overview
24 |
25 | [Click here for a Video Preview](https://youtu.be/hPjuXTlpybg)
26 |
27 | 
28 |
29 | When it comes to video conferencing, screen sharing is a critical feature that enables remote teams to collaborate effectively. However, the limitations of conventional screen sharing options can hinder this collaboration. For instance, if you're working on multiple windows simultaneously, you may need to switch between them while sharing your screen. In such cases, the inability to share multiple windows at once without sharing your entire desktop can be a significant drawback. Similarly, if you need to add another window to the stream, you have to re-share your screen, which can be disruptive and inconvenient.
30 |
31 | > **FrameCast provides a way to overcome these limitations by allowing you to share a specific region of your screen instead of the entire screen or a single window.**
32 |
33 | This can help you avoid distractions and maintain a focused, streamlined presentation. Additionally, it enables you to switch between multiple windows while sharing your screen without the need to re-share your screen, enhancing your ability to collaborate with others.
34 |
35 | The application works through [FFMPEG](https://ffmpeg.org/), an open-source video and audio processing software, to stream a portion of your screen to a separate window that you can then select for sharing in your video conferencing application. The viewer window is fully customizable, allowing you to adjust its size, position, bit-rate and frame-rate to suit your preferences.
36 |
37 | **This is especially useful when you are running on an Ultra-wide display.**
38 |
39 | ## Installation
40 |
41 | Download the [Latest Version](https://github.com/nathan-fiscaletti/framecast/releases/latest) from the releases page.
42 |
43 | ## FAQ
44 |
45 | #### Why am I getting a "File is corrupted" error when I run the macOS installer?
46 |
47 | - If you receive a "File is corrupted" error when trying to open the macOS installer, it is likely due to the "Quarantine" attribute being set on the file by macOS. This happens when the file is downloaded from the internet and can prevent you from opening the installer.
48 |
49 | To fix this issue, you can remove the quarantine attribute from the file using the `xattr` command in the terminal. Open the terminal and run the following command:
50 |
51 | ```bash
52 | xattr -c /path/to/FrameCast.Setup.darwin_x64.dmg
53 | ```
54 |
55 | This will remove the quarantine attribute from the installer and allow you to open it.
56 |
57 | ## Usage
58 |
59 | To use FrameCast, follow these steps:
60 |
61 | 1. Open the application.
62 | 2. Select the region of your screen that you want to share.
63 | 3. Start the stream in the application.
64 | 4. Start sharing your screen in your video conferencing application.
65 | 5. Select the "FrameCast - Viewer" window to share the selected region.
66 |
67 | ## Contributing
68 |
69 | Contributions to FrameCast are welcome and appreciated. To contribute to the project, follow these steps:
70 |
71 | 1. Fork the project.
72 | 2. Create a new branch for your changes.
73 | 3. Make your changes and test them thoroughly.
74 | 4. Submit a pull request with your changes.
75 |
76 | ## License
77 |
78 | FrameCast is licensed under the [MIT License](./LICENSE).
79 |
--------------------------------------------------------------------------------
/public/js/video-stream/stream-video.js:
--------------------------------------------------------------------------------
1 | const { spawn } = require('child_process');
2 | const util = require('util');
3 | const exec = util.promisify(require('child_process').exec);
4 |
5 | const chalk = require('chalk');
6 |
7 | const ffmpeg = require('ffmpeg-static').replace(
8 | 'app.asar',
9 | 'app.asar.unpacked'
10 | );
11 |
12 | module.exports = {
13 | startVideoStreamProcess: async ({
14 | offsetX = 0,
15 | offsetY = 0,
16 | width = 500,
17 | height = 500,
18 | frameRate = 60,
19 | bitRate = "10M",
20 | streamPort = 8081,
21 | showRegion = false,
22 | screenId = 1,
23 | scaleFactor = 1,
24 | streamSecret = "password"
25 | }) => {
26 | let args;
27 | switch (process.platform) {
28 | case 'win32': {
29 | args = [
30 | "-probesize", "10M",
31 | "-f", "gdigrab", "-framerate", `${frameRate}`, "-offset_x", `${offsetX}`, "-offset_y", `${offsetY}`, "-video_size", `${width}x${height}`, ...(showRegion ? ["-show_region", "1"] : []), "-i", "desktop",
32 | "-f", "mpegts", "-codec:v", "mpeg1video", "-s", `${width}x${height}`, "-b:v", bitRate, "-bf", "0", `http://localhost:${streamPort}/${streamSecret}`
33 | ];
34 | break;
35 | }
36 |
37 | case 'linux': {
38 | args = [
39 | '-video_size', `${width}x${height}`, `-framerate`, `${frameRate}`, ...(showRegion ? ['-show_region', '1', '-region_border', '8'] : []), '-f', 'x11grab', '-i', `:0.0+${offsetX},${offsetY}`,
40 | "-f", "mpegts", "-codec:v", "mpeg1video", "-s", `${width}x${height}`, "-b:v", bitRate, "-bf", "0", `http://localhost:${streamPort}/${streamSecret}`
41 | ];
42 | break;
43 | }
44 |
45 | case 'darwin': {
46 | const screenIndex = await getCurrentScreenIndex(screenId)
47 |
48 | // Determine the FFmpeg index for the screen on which the recording
49 | // will be made. This is necessary because the index is not the same
50 | // as the screen ID.
51 | const output = await new Promise((resolve, reject) => {
52 | try {
53 | let deviceListOutput = "";
54 | const listDevicesCommand = spawn(
55 | ffmpeg,
56 | [
57 | "-f", "avfoundation", "-list_devices", "true", "-i", "\"\""
58 | ]
59 | );
60 | listDevicesCommand.stderr.on('data', function (data) {
61 | deviceListOutput += data.toString();
62 | });
63 | listDevicesCommand.stdout.on('data', function (data) {
64 | deviceListOutput += data.toString();
65 | });
66 | listDevicesCommand.on('close', function (code) {
67 | return resolve(deviceListOutput);
68 | });
69 | } catch (err) {
70 | reject(err);
71 | }
72 | });
73 |
74 | const lines = output.split(/\r?\n/);
75 | const line = lines.find(line => line.includes(`Capture screen ${screenIndex}`))
76 | if (!line) {
77 | throw new Error(`Unable to find Capture screen ${screenIndex} in output.`);
78 | }
79 |
80 | const ffmpegScreenIndex = parseInt(line.split("[").pop().split("]").shift().trim())
81 |
82 | // To list screens: `./node_modules/ffmpeg-static/ffmpeg -f avfoundation -list_devices true -i "" 2>&1 | grep indev | grep screen`
83 |
84 | // Update the width, height, and offset values to account for the
85 | // screen scaling factor.
86 | width = width * scaleFactor;
87 | height = height * scaleFactor;
88 | offsetX = offsetX * scaleFactor;
89 | offsetY = offsetY * scaleFactor;
90 |
91 | args = [
92 | "-probesize", "50M",
93 | "-f", "avfoundation", "-framerate", `${frameRate}`, "-video_device_index", `${ffmpegScreenIndex}`, "-i", "\":none\"", "-vf", `crop=${width}:${height}:${offsetX}:${offsetY}`, '-capture_cursor', 'true',
94 | "-f", "mpegts", "-codec:v", "mpeg1video", "-s", `${width}x${height}`, "-b:v", bitRate, "-bf", "0", `http://localhost:${streamPort}/${streamSecret}`
95 | ];
96 | break;
97 | }
98 |
99 | default: {
100 | throw new Error(`Unsupported platform: ${process.platform}`);
101 | }
102 | }
103 |
104 | console.log(`$ ${chalk.gray(`${ffmpeg} ${args.join(" ")}`)}`);
105 | return spawn(
106 | ffmpeg, args, { stdio: 'inherit', windowsHide: true }
107 | );
108 | }
109 | }
110 |
111 | async function getCurrentScreenIndex(screenId) {
112 | const command = `system_profiler SPDisplaysDataType -json`;
113 | try {
114 | const { stdout, stderr } = await exec(command);
115 | if (stderr) {
116 | console.error(`stderr: ${stderr}`);
117 | return;
118 | }
119 | const displayData = JSON.parse(stdout);
120 | const displays = displayData.SPDisplaysDataType.flatMap(d => d.spdisplays_ndrvs)
121 | const displayIndexLookup = displays.map(d => d._spdisplays_displayID)
122 | // eslint-disable-next-line eqeqeq
123 | return displayIndexLookup.findIndex(x => x == screenId)
124 | } catch (error) {
125 | console.error('Failed to fetch display data:', error);
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/public/js/windows.js:
--------------------------------------------------------------------------------
1 | const { BrowserWindow } = require('electron');
2 |
3 | const path = require('path');
4 |
5 | const settings = require('./settings');
6 | const stream = require('./stream');
7 |
8 | const { getStreamScreen, getStreamRegion } = require('./stream');
9 |
10 | let viewWindow;
11 | let regionSelectionWindow;
12 | let settingsWindow;
13 | let controlWindow;
14 |
15 | const settingsWindowLocation = { x: -1, y: -1 };
16 |
17 | function createViewWindow({ app }) {
18 | if (getViewWindow() && !getViewWindow().isDestroyed()) {
19 | getViewWindow().focus();
20 | return;
21 | }
22 |
23 | const _viewWindow = new BrowserWindow({
24 | x: getControlWindow().getPosition()[0],
25 | y: getControlWindow().getPosition()[1] + getControlWindow().getSize()[1],
26 | useContentSize: true,
27 | skipTaskbar: true,
28 | maximizable: false,
29 | icon: path.join(__dirname, "..", "icon.png"),
30 | closable: false,
31 | title: "FrameCast - Viewer",
32 | minimizable: false,
33 | frame: false,
34 | resizable: false,
35 | alwaysOnTop: true,
36 | opacity: settings.get().previewVisible ? 1 : 0,
37 | webPreferences: {
38 | nodeIntegration: true,
39 | enableRemoteModule: true,
40 | contextIsolation: false
41 | }
42 | });
43 |
44 | viewWindow = _viewWindow;
45 |
46 | const appURL = app.isPackaged
47 | ? `file://${__dirname}/../index.html?content=viewer`
48 | : "http://localhost:3000?content=viewer";
49 | getViewWindow().loadURL(appURL);
50 |
51 | // Automatically open Chrome's DevTools in development mode.
52 | // if (!app.isPackaged) {
53 | // getViewWindow().webContents.openDevTools();
54 | // }
55 |
56 | getControlWindow().focus();
57 | }
58 |
59 | function getViewWindow() {
60 | return viewWindow;
61 | }
62 |
63 | function closeViewWindow() {
64 | if (viewWindow) {
65 | viewWindow.close();
66 | viewWindow = null;
67 | }
68 | }
69 |
70 | function createRegionSelectionWindow({ app }) {
71 | if (getRegionSelectionWindow() && !getRegionSelectionWindow().isDestroyed()) {
72 | getRegionSelectionWindow().focus();
73 | return;
74 | }
75 |
76 | const windowBounds = { ...getStreamRegion() };
77 | if (process.platform === 'darwin') {
78 | windowBounds.x += getStreamScreen().bounds.x;
79 | windowBounds.y += getStreamScreen().bounds.y;
80 | }
81 |
82 | const _regionSelectionWindow = new BrowserWindow({
83 | ...windowBounds,
84 | maximizable: false,
85 | frame: false,
86 | alwaysOnTop: true,
87 | roundedCorners: false,
88 | opacity: 0.75,
89 | width: getStreamRegion().width,
90 | height: getStreamRegion().height,
91 | title: "Select Recording Region",
92 | icon: path.join(__dirname, "..", "icon.png"),
93 | webPreferences: {
94 | nodeIntegration: true,
95 | enableRemoteModule: true,
96 | contextIsolation: false
97 | }
98 | });
99 |
100 | regionSelectionWindow = _regionSelectionWindow;
101 |
102 | const appURL = app.isPackaged
103 | ? `file://${__dirname}/../index.html?content=selector`
104 | : "http://localhost:3000?content=selector";
105 | getRegionSelectionWindow().loadURL(appURL);
106 |
107 | // Automatically open Chrome's DevTools in development mode.
108 | // if (!app.isPackaged) {
109 | // getRegionSelectionWindow().webContents.openDevTools();
110 | // }
111 | }
112 |
113 | function getRegionSelectionWindow() {
114 | return regionSelectionWindow;
115 | }
116 |
117 | function createSettingsWindow({ app = {}, tab = 0 }) {
118 | if (getSettingsWindow() && !getSettingsWindow().isDestroyed()) {
119 | getSettingsWindow().focus();
120 | } else {
121 | const _settingsWindow = new BrowserWindow({
122 | parent: getControlWindow(),
123 | center: true,
124 | ...(
125 | settingsWindowLocation.x !== -1 &&
126 | settingsWindowLocation.y !== -1 ? settingsWindowLocation : {}
127 | ),
128 | width: 550,
129 | height: 850,
130 | maximizable: false,
131 | autoHideMenuBar: true,
132 | alwaysOnTop: true,
133 | roundedCorners: false,
134 | minimizable: false,
135 | icon: path.join(__dirname, "..", "icon.png"),
136 | title: "FrameCast - Settings",
137 | webPreferences: {
138 | nodeIntegration: true,
139 | enableRemoteModule: true,
140 | contextIsolation: false
141 | }
142 | });
143 |
144 | _settingsWindow.on("move", () => {
145 | const [x, y] = getSettingsWindow().getPosition();
146 | settingsWindowLocation.x = x;
147 | settingsWindowLocation.y = y;
148 | });
149 |
150 | settingsWindow = _settingsWindow;
151 | }
152 |
153 | const appURL = app.isPackaged
154 | ? `file://${__dirname}/../index.html?content=settings&platform=${process.platform}&version=${app.getVersion()}&tab=${tab}`
155 | : `http://localhost:3000?content=settings&platform=${process.platform}&version=${app.getVersion()}&tab=${tab}`;
156 | getSettingsWindow().loadURL(appURL);
157 |
158 | // Automatically open Chrome's DevTools in development mode.
159 | // if (!app.isPackaged) {
160 | // getSettingsWindow().webContents.openDevTools();
161 | // }
162 | }
163 |
164 | function getSettingsWindow() {
165 | return settingsWindow;
166 | }
167 |
168 | function createControlWindow({ app, screen }) {
169 | stream.setStreamScreen(screen.getPrimaryDisplay());
170 |
171 | const _controlWindow = new BrowserWindow({
172 | modal: true,
173 | center: true,
174 | width: 432,
175 | height: 140,
176 | maximizable: false,
177 | minimizable: false,
178 | autoHideMenuBar: true,
179 | alwaysOnTop: true,
180 | resizable: false,
181 | roundedCorners: false,
182 | icon: path.join(__dirname, "..", "icon.png"),
183 | title: "FrameCast",
184 | webPreferences: {
185 | nodeIntegration: true,
186 | enableRemoteModule: true,
187 | contextIsolation: false
188 | }
189 | });
190 |
191 | _controlWindow.on("move", () => {
192 | if (getViewWindow() && !getViewWindow().isDestroyed()) {
193 | getViewWindow().setPosition(
194 | getControlWindow().getPosition()[0],
195 | getControlWindow().getPosition()[1] + getControlWindow().getSize()[1],
196 | );
197 | }
198 | });
199 |
200 | _controlWindow.on("closed", () => {
201 | process.exit(0);
202 | });
203 |
204 | controlWindow = _controlWindow;
205 |
206 | const appURL = app.isPackaged
207 | ? `file://${__dirname}/../index.html?content=control&platform=${process.platform}`
208 | : `http://localhost:3000?content=control&platform=${process.platform}`;
209 | getControlWindow().loadURL(appURL);
210 |
211 | // Automatically open Chrome's DevTools in development mode.
212 | // if (!app.isPackaged) {
213 | // getControlWindow().webContents.openDevTools();
214 | // }
215 | }
216 |
217 | function getControlWindow() {
218 | return controlWindow;
219 | }
220 |
221 | function closeRegionSelectionWindow() {
222 | if (regionSelectionWindow) {
223 | regionSelectionWindow.close();
224 | regionSelectionWindow = null;
225 | }
226 | }
227 |
228 | function restrictRegionSelectionWindowSize(maxWidth, maxHeight) {
229 | if (regionSelectionWindow) {
230 | regionSelectionWindow.setMaximumSize(maxWidth, maxHeight);
231 | }
232 | }
233 |
234 | function closeSettingsWindow() {
235 | if (settingsWindow) {
236 | settingsWindow.close();
237 | settingsWindow = null;
238 | }
239 | }
240 |
241 | module.exports = {
242 | createViewWindow,
243 | getViewWindow,
244 | closeViewWindow,
245 |
246 | createRegionSelectionWindow,
247 | getRegionSelectionWindow,
248 | closeRegionSelectionWindow,
249 | restrictRegionSelectionWindowSize,
250 |
251 | createSettingsWindow,
252 | getSettingsWindow,
253 | closeSettingsWindow,
254 |
255 | createControlWindow,
256 | getControlWindow,
257 | };
--------------------------------------------------------------------------------
/src/Settings.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { LoadingButton } from '@mui/lab';
4 | import {
5 | Alert, AlertTitle, AppBar, Box, Button,
6 | Checkbox, Collapse, Dialog, DialogActions,
7 | DialogContent, DialogContentText, DialogTitle, Divider,
8 | FormControl, InputAdornment, InputLabel, Link,
9 | MenuItem, Paper, Select, Slide,
10 | Slider, Snackbar, Switch, Tab,
11 | Tabs, TextField, Tooltip, Typography
12 | } from '@mui/material';
13 | import { styled } from '@mui/material/styles';
14 |
15 | import AnalyticsOutlinedIcon from '@mui/icons-material/AnalyticsOutlined';
16 | import AttributionIcon from '@mui/icons-material/Attribution';
17 | import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
18 | import GavelOutlinedIcon from '@mui/icons-material/GavelOutlined';
19 | import GitHubIcon from '@mui/icons-material/GitHub';
20 | import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
21 | import PersonOutlinedIcon from '@mui/icons-material/PersonOutlined';
22 | import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
23 | import SettingsIcon from '@mui/icons-material/Settings';
24 |
25 | const { ipcRenderer, shell } = window.require('electron');
26 |
27 | const PrettoSlider = styled(Slider)({
28 | color: '#52af77',
29 | height: 8,
30 | '& .MuiSlider-track': {
31 | border: 'none',
32 | },
33 | '& .MuiSlider-thumb': {
34 | height: 24,
35 | width: 24,
36 | backgroundColor: '#fff',
37 | border: '2px solid currentColor',
38 | '&:focus, &:hover, &.Mui-active, &.Mui-focusVisible': {
39 | boxShadow: 'inherit',
40 | },
41 | '&:before': {
42 | display: 'none',
43 | },
44 | },
45 | '& .MuiSlider-valueLabel': {
46 | lineHeight: 1.2,
47 | fontSize: 12,
48 | background: 'unset',
49 | padding: 0,
50 | width: 32,
51 | height: 32,
52 | borderRadius: '50% 50% 50% 0',
53 | backgroundColor: '#52af77',
54 | transformOrigin: 'bottom left',
55 | transform: 'translate(50%, -100%) rotate(-45deg) scale(0)',
56 | '&:before': { display: 'none' },
57 | '&.MuiSlider-valueLabelOpen': {
58 | transform: 'translate(50%, -100%) rotate(-45deg) scale(1)',
59 | },
60 | '& > *': {
61 | transform: 'rotate(45deg)',
62 | },
63 | },
64 | });
65 |
66 | const SlideUpDialogTransition = React.forwardRef(function Transition(props, ref) {
67 | return ;
68 | });
69 |
70 | export default function Settings() {
71 | const urlParams = new URLSearchParams(window.location.search);
72 | const platform = urlParams.get('platform');
73 | const version = urlParams.get('version');
74 |
75 | const [streamPort, setStreamPort] = React.useState(0);
76 | const [webSocketPort, setWebSocketPort] = React.useState(0);
77 | const [defaultScreenCaptureWidth, setDefaultScreenCaptureWidth] = React.useState(0)
78 | const [defaultScreenCaptureHeight, setDefaultScreenCaptureHeight] = React.useState(0)
79 | const [previewVisible, setPreviewVisible] = React.useState(false);
80 | const [enableAnalytics, setEnableAnalytics] = React.useState(false);
81 | const [showRegion, setShowRegion] = React.useState(false);
82 | const [frameRate, setFrameRate] = React.useState(0);
83 | const [bitRate, setBitRate] = React.useState(0);
84 | const [regionBorderSize, setRegionBorderSize] = React.useState(1);
85 | const [trackingId, setTrackingId] = React.useState(null);
86 |
87 | React.useEffect(() => {
88 | document.querySelector("body").style = "overflow-y: scroll;";
89 |
90 | ipcRenderer.on("settings", (event, settings) => {
91 | const urlParams = new URLSearchParams(window.location.search);
92 | const platform = urlParams.get('platform');
93 |
94 | switch (platform) {
95 | case 'linux': {
96 | setRegionBorderSize(settings.regionBorderSize);
97 | setShowRegion(settings.showRegion);
98 | break;
99 | }
100 |
101 | case 'win32': {
102 | setShowRegion(settings.showRegion);
103 | break;
104 | }
105 |
106 | default: { }
107 | }
108 |
109 | setFrameRate(settings.frameRate);
110 | setBitRate(settings.bitRate);
111 | setStreamPort(settings.streamPort);
112 | setDefaultScreenCaptureWidth(settings.defaultScreenCaptureWidth)
113 | setDefaultScreenCaptureHeight(settings.defaultScreenCaptureHeight)
114 | setWebSocketPort(settings.webSocketPort);
115 | setPreviewVisible(settings.previewVisible);
116 | setEnableAnalytics(settings.enableAnalytics);
117 | setTrackingId(settings.clientId);
118 | });
119 |
120 | ipcRenderer.send("getSettings");
121 | }, []);
122 |
123 | const [saveFinished, setSaveFinished] = React.useState(false);
124 | const [error, setError] = React.useState(null);
125 |
126 | const [selectedTab, setSelectedTab] = React.useState(parseInt(urlParams.get('tab')));
127 |
128 | const [update, setUpdate] = React.useState(null);
129 | const [checkingForUpdate, setCheckingForUpdate] = React.useState(false);
130 | const [showUpToDate, setShowUpToDate] = React.useState(false);
131 | const [updateError, setUpdateError] = React.useState(null);
132 |
133 | const save = () => {
134 | try {
135 | let settings;
136 |
137 | switch (platform) {
138 | case 'linux': {
139 | settings = {
140 | regionBorderSize,
141 | showRegion,
142 | };
143 | break;
144 | }
145 |
146 | case 'win32': {
147 | settings = {
148 | showRegion,
149 | };
150 | break;
151 | }
152 |
153 | case 'darwin': {
154 | settings = {
155 | showRegion: true,
156 | };
157 | break;
158 | }
159 |
160 | default: { }
161 | }
162 |
163 | settings = {
164 | ...settings,
165 | webSocketPort,
166 | streamPort,
167 | frameRate,
168 | bitRate,
169 | previewVisible,
170 | enableAnalytics,
171 | defaultScreenCaptureWidth,
172 | defaultScreenCaptureHeight
173 | }
174 |
175 | ipcRenderer.send("updateSettings", settings);
176 | setError(null);
177 | setSaveFinished(true);
178 | } catch (err) {
179 | setError(err);
180 | setSaveFinished(true);
181 | }
182 | };
183 |
184 | const checkForUpdate = () => {
185 | setCheckingForUpdate(true);
186 | fetch("https://api.github.com/repos/nathan-fiscaletti/framecast/releases/latest")
187 | .then(res => res.json())
188 | .then(release => {
189 | if (release.tag_name !== `v${version}`) {
190 | setUpdate(release);
191 | } else {
192 | setShowUpToDate(true);
193 | }
194 | })
195 | .catch(err => { })
196 | .finally(() => setCheckingForUpdate(false));
197 | };
198 |
199 | const copyTrackingId = () => {
200 | navigator.clipboard.writeText(trackingId);
201 | };
202 |
203 | return (<>
204 |
205 | setSelectedTab(newValue)}>
206 |
207 |
208 |
209 |
210 |
215 | }
216 | onClick={() => save()}
217 | sx={{ my: 1, ml: 3 }}
218 | />
219 |
220 |
221 |
222 |
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 |
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 | }
--------------------------------------------------------------------------------