1
0
Fork 0
mirror of https://gitlab.com/futo-org/fcast.git synced 2025-08-08 02:02:49 +00:00

Initial commit.

This commit is contained in:
Koen 2023-06-20 08:45:01 +02:00
commit c8394f6a8e
99 changed files with 8173 additions and 0 deletions

View file

@ -0,0 +1,4 @@
import { app } from 'electron';
import Main from './Main';
Main.main(app);

View file

@ -0,0 +1,42 @@
import mdns = require('mdns-js');
const cp = require('child_process');
const os = require('os');
export class DiscoveryService {
private service: any;
private static getComputerName() {
switch (process.platform) {
case "win32":
return process.env.COMPUTERNAME;
case "darwin":
return cp.execSync("scutil --get ComputerName").toString().trim();
case "linux":
const prettyname = cp.execSync("hostnamectl --pretty").toString().trim();
return prettyname === "" ? os.hostname() : prettyname;
default:
return os.hostname();
}
}
start() {
if (this.service) {
return;
}
const name = `FCast-${DiscoveryService.getComputerName()}`;
console.log("Discovery service started.", name);
this.service = mdns.createAdvertisement(mdns.tcp('_fcast'), 46899, { name: name });
this.service.start();
}
stop() {
if (!this.service) {
return;
}
this.service.stop();
this.service = null;
}
}

View file

@ -0,0 +1,115 @@
import net = require('net');
import { FCastSession } from './FCastSession';
import { EventEmitter } from 'node:events';
import { PlaybackUpdateMessage, PlayMessage, SeekMessage, SetVolumeMessage, VolumeUpdateMessage } from './Packets';
import { dialog } from 'electron';
import Main from './Main';
export class FCastService {
emitter = new EventEmitter();
private server: net.Server;
private sessions: FCastSession[] = [];
start() {
if (this.server != null) {
return;
}
this.server = net.createServer()
.listen(46899)
.on("connection", this.handleConnection.bind(this))
.on("error", this.handleServerError.bind(this));
}
stop() {
if (this.server == null) {
return;
}
const server = this.server;
this.server = null;
server.close();
}
sendPlaybackUpdate(value: PlaybackUpdateMessage) {
console.info("Sending playback update.", value);
this.sessions.forEach(session => {
try {
session.sendPlaybackUpdate(value);
} catch (e) {
console.warn("Failed to send update.", e);
session.socket.end();
}
});
}
sendVolumeUpdate(value: VolumeUpdateMessage) {
console.info("Sending volume update.", value);
this.sessions.forEach(session => {
try {
session.sendVolumeUpdate(value);
} catch (e) {
console.warn("Failed to send update.", e);
session.socket.end();
}
});
}
private async handleServerError(err: NodeJS.ErrnoException) {
console.error("Server error:", err);
const restartPrompt = await dialog.showMessageBox({
type: 'error',
title: 'Failed to start',
message: 'The application failed to start properly.',
buttons: ['Restart', 'Close'],
defaultId: 0,
cancelId: 1
});
if (restartPrompt.response === 0) {
Main.application.relaunch();
Main.application.exit(0);
} else {
Main.application.exit(0);
}
}
private handleConnection(socket: net.Socket) {
console.log(`new connection from ${socket.remoteAddress}:${socket.remotePort}`);
const session = new FCastSession(socket);
session.emitter.on("play", (body: PlayMessage) => { this.emitter.emit("play", body) });
session.emitter.on("pause", () => { this.emitter.emit("pause") });
session.emitter.on("resume", () => { this.emitter.emit("resume") });
session.emitter.on("stop", () => { this.emitter.emit("stop") });
session.emitter.on("seek", (body: SeekMessage) => { this.emitter.emit("seek", body) });
session.emitter.on("setvolume", (body: SetVolumeMessage) => { this.emitter.emit("setvolume", body) });
this.sessions.push(session);
socket.on("error", (err) => {
console.warn(`Error from ${socket.remoteAddress}:${socket.remotePort}.`, err);
socket.destroy();
});
socket.on("data", buffer => {
try {
session.processBytes(buffer);
} catch (e) {
console.warn(`Error while handling packet from ${socket.remoteAddress}:${socket.remotePort}.`, e);
socket.end();
}
});
socket.on("close", () => {
const index = this.sessions.indexOf(session);
if (index != -1) {
this.sessions.splice(index, 1);
}
});
}
}

View file

@ -0,0 +1,178 @@
import net = require('net');
import { EventEmitter } from 'node:events';
import { PlaybackUpdateMessage, PlayMessage, SeekMessage, SetVolumeMessage, VolumeUpdateMessage } from './Packets';
enum SessionState {
Idle = 0,
WaitingForLength,
WaitingForData,
Disconnected,
};
enum Opcode {
None = 0,
Play = 1,
Pause = 2,
Resume = 3,
Stop = 4,
Seek = 5,
PlaybackUpdate = 6,
VolumeUpdate = 7,
SetVolume = 8
};
const LENGTH_BYTES = 4;
const MAXIMUM_PACKET_LENGTH = 32000;
export class FCastSession {
buffer: Buffer = Buffer.alloc(MAXIMUM_PACKET_LENGTH);
bytesRead = 0;
packetLength = 0;
socket: net.Socket;
state: SessionState;
emitter = new EventEmitter();
constructor(socket: net.Socket) {
this.socket = socket;
this.state = SessionState.WaitingForLength;
}
sendPlaybackUpdate(value: PlaybackUpdateMessage) {
this.send(Opcode.PlaybackUpdate, value);
}
sendVolumeUpdate(value: VolumeUpdateMessage) {
this.send(Opcode.VolumeUpdate, value);
}
private send(opcode: number, message = null) {
const json = message ? JSON.stringify(message) : null;
let data: Uint8Array;
if (json) {
const utf8Encode = new TextEncoder();
data = utf8Encode.encode(json);
} else {
data = new Uint8Array(0);
}
const size = 1 + data.length;
const header = Buffer.alloc(4 + 1);
header.writeUint32LE(size, 0);
header[4] = opcode;
let packet: Buffer;
if (data.length > 0) {
packet = Buffer.concat([ header, data ]);
} else {
packet = header;
}
this.socket.write(packet);
}
processBytes(receivedBytes: Buffer) {
//TODO: Multithreading?
if (receivedBytes.length == 0) {
return;
}
console.log(`${receivedBytes.length} bytes received from ${this.socket.remoteAddress}:${this.socket.remotePort}`);
switch (this.state) {
case SessionState.WaitingForLength:
this.handleLengthBytes(receivedBytes);
break;
case SessionState.WaitingForData:
this.handlePacketBytes(receivedBytes);
break;
default:
console.log(`Data received is unhandled in current session state ${this.state}.`);
break;
}
}
private handleLengthBytes(receivedBytes: Buffer) {
const bytesToRead = Math.min(LENGTH_BYTES, receivedBytes.length);
const bytesRemaining = receivedBytes.length - bytesToRead;
receivedBytes.copy(this.buffer, this.bytesRead, 0, bytesToRead);
this.bytesRead += bytesToRead;
console.log(`handleLengthBytes: Read ${bytesToRead} bytes from packet`);
if (this.bytesRead >= LENGTH_BYTES) {
this.state = SessionState.WaitingForData;
this.packetLength = this.buffer.readUInt32LE(0);
this.bytesRead = 0;
console.log(`Packet length header received from ${this.socket.remoteAddress}:${this.socket.remotePort}: ${this.packetLength}`);
if (this.packetLength > MAXIMUM_PACKET_LENGTH) {
console.log(`Maximum packet length is 32kB, killing socket ${this.socket.remoteAddress}:${this.socket.remotePort}: ${this.packetLength}`);
this.socket.end();
this.state = SessionState.Disconnected;
return;
}
if (bytesRemaining > 0) {
console.log(`${bytesRemaining} remaining bytes ${this.socket.remoteAddress}:${this.socket.remotePort} pushed to handlePacketBytes`);
this.handlePacketBytes(receivedBytes.slice(bytesToRead));
}
}
}
private handlePacketBytes(receivedBytes: Buffer) {
const bytesToRead = Math.min(this.packetLength, receivedBytes.length);
const bytesRemaining = receivedBytes.length - bytesToRead;
receivedBytes.copy(this.buffer, this.bytesRead, 0, bytesToRead);
this.bytesRead += bytesToRead;
console.log(`handlePacketBytes: Read ${bytesToRead} bytes from packet`);
if (this.bytesRead >= this.packetLength) {
console.log(`Packet finished receiving from ${this.socket.remoteAddress}:${this.socket.remotePort} of ${this.packetLength} bytes.`);
this.handlePacket();
this.state = SessionState.WaitingForLength;
this.packetLength = 0;
this.bytesRead = 0;
if (bytesRemaining > 0) {
console.log(`${bytesRemaining} remaining bytes ${this.socket.remoteAddress}:${this.socket.remotePort} pushed to handleLengthBytes`);
this.handleLengthBytes(receivedBytes.slice(bytesToRead));
}
}
}
private handlePacket() {
console.log(`Processing packet of ${this.bytesRead} bytes from ${this.socket.remoteAddress}:${this.socket.remotePort}`);
const opcode = this.buffer[0];
const body = this.packetLength > 1 ? this.buffer.toString('utf8', 1, this.packetLength) : null;
console.log('body', body);
try {
switch (opcode) {
case Opcode.Play:
this.emitter.emit("play", JSON.parse(body) as PlayMessage);
break;
case Opcode.Pause:
this.emitter.emit("pause");
break;
case Opcode.Resume:
this.emitter.emit("resume");
break;
case Opcode.Stop:
this.emitter.emit("stop");
break;
case Opcode.Seek:
this.emitter.emit("seek", JSON.parse(body) as SeekMessage);
break;
case Opcode.SetVolume:
this.emitter.emit("setvolume", JSON.parse(body) as SetVolumeMessage);
break;
}
} catch (e) {
console.warn(`Error handling packet from ${this.socket.remoteAddress}:${this.socket.remotePort}.`, e);
}
}
}

View file

@ -0,0 +1,163 @@
import { BrowserWindow, ipcMain, IpcMainEvent, nativeImage, Tray, Menu, dialog } from 'electron';
import path = require('path');
import { FCastService } from './FCastService';
import { PlaybackUpdateMessage, SetVolumeMessage, VolumeUpdateMessage } from './Packets';
import { DiscoveryService } from './DiscoveryService';
import { Updater } from './Updater';
export default class Main {
static mainWindow: Electron.BrowserWindow;
static application: Electron.App;
static service: FCastService;
static discoveryService: DiscoveryService;
static tray: Tray;
private static createTray() {
const icon = (process.platform === 'win32') ? path.join(__dirname, 'app.ico') : path.join(__dirname, 'app.png');
const trayicon = nativeImage.createFromPath(icon)
const tray = new Tray(trayicon.resize({ width: 16 }));
const contextMenu = Menu.buildFromTemplate([
{
label: 'Check for updates',
click: async () => {
try {
const updater = new Updater(path.join(__dirname, '../'), 'https://releases.grayjay.app/fcastreceiver');
if (await updater.update()) {
const restartPrompt = await dialog.showMessageBox({
type: 'info',
title: 'Update completed',
message: 'The application has been updated. Restart now to apply the changes.',
buttons: ['Restart'],
defaultId: 0
});
console.log('Update completed');
// Restart the app if the user clicks the 'Restart' button
if (restartPrompt.response === 0) {
Main.application.relaunch();
Main.application.exit(0);
}
} else {
await dialog.showMessageBox({
type: 'info',
title: 'Already up-to-date',
message: 'The application is already on the latest version.',
buttons: ['OK'],
defaultId: 0
});
}
} catch (err) {
await dialog.showMessageBox({
type: 'error',
title: 'Failed to update',
message: 'The application failed to update.',
buttons: ['OK'],
defaultId: 0
});
console.error('Failed to update:', err);
}
},
},
{
type: 'separator',
},
{
label: 'Restart',
click: () => {
this.application.relaunch();
this.application.exit(0);
}
},
{
label: 'Quit',
click: () => {
this.application.quit();
}
}
])
tray.setContextMenu(contextMenu);
this.tray = tray;
}
private static onClose() {
Main.mainWindow = null;
}
private static onReady() {
Main.createTray();
Main.discoveryService = new DiscoveryService();
Main.discoveryService.start();
Main.service = new FCastService();
Main.service.emitter.on("play", (message) => {
if (Main.mainWindow == null) {
Main.mainWindow = new BrowserWindow({
fullscreen: true,
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
});
Main.mainWindow.setAlwaysOnTop(false, 'pop-up-menu');
Main.mainWindow.show();
Main.mainWindow.loadFile(path.join(__dirname, 'index.html'));
Main.mainWindow.on('ready-to-show', () => {
Main.mainWindow?.webContents?.send("play", message);
});
Main.mainWindow.on('closed', Main.onClose);
} else {
Main.mainWindow?.webContents?.send("play", message);
}
});
Main.service.emitter.on("pause", () => Main.mainWindow?.webContents?.send("pause"));
Main.service.emitter.on("resume", () => Main.mainWindow?.webContents?.send("resume"));
Main.service.emitter.on("stop", () => {
Main.mainWindow.close();
Main.mainWindow = null;
});
Main.service.emitter.on("seek", (message) => Main.mainWindow?.webContents?.send("seek", message));
Main.service.emitter.on("setvolume", (message) => Main.mainWindow?.webContents?.send("setvolume", message));
Main.service.start();
ipcMain.on('toggle-full-screen', () => {
const window = Main.mainWindow;
if (!window) {
return;
}
window.setFullScreen(!window.isFullScreen());
});
ipcMain.on('exit-full-screen', () => {
const window = Main.mainWindow;
if (!window) {
return;
}
window.setFullScreen(false);
});
ipcMain.on('send-playback-update', (event: IpcMainEvent, value: PlaybackUpdateMessage) => {
Main.service.sendPlaybackUpdate(value);
});
ipcMain.on('send-volume-update', (event: IpcMainEvent, value: VolumeUpdateMessage) => {
Main.service.sendVolumeUpdate(value);
});
}
static main(app: Electron.App) {
Main.application = app;
Main.application.on('ready', Main.onReady);
Main.application.on('window-all-closed', () => { });
}
}

View file

@ -0,0 +1,33 @@
export class PlayMessage {
constructor(
public container: String,
public url: String = null,
public content: String = null,
public time: number = null
) {}
}
export class SeekMessage {
constructor(
public time: number,
) {}
}
export class PlaybackUpdateMessage {
constructor(
public time: number,
public state: number
) {}
}
export class VolumeUpdateMessage {
constructor(
public volume: number
) {}
}
export class SetVolumeMessage {
constructor(
public volume: number,
) {}
}

View file

@ -0,0 +1,101 @@
import * as fs from 'fs';
import * as https from 'https';
import * as path from 'path';
import { URL } from 'url';
export class Updater {
private basePath: string;
private baseUrl: string;
private appFiles: string[];
constructor(basePath: string, baseUrl: string) {
this.basePath = basePath;
this.baseUrl = baseUrl;
this.appFiles = [
'dist/app.ico',
'dist/index.html',
'dist/style.css',
'dist/app.png',
'dist/preload.js',
'dist/video-js.min.css',
'dist/bundle.js',
'dist/renderer.js',
'dist/video.min.js',
'package.json'
];
}
private async fetchJSON(url: string): Promise<any> {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (err) {
reject(err);
}
});
}).on('error', (err) => {
reject(err);
});
});
}
private async downloadFile(url: string, destination: string): Promise<void> {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(destination);
https.get(url, (response) => {
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}).on('error', (err) => {
file.close();
reject(err);
});
});
}
private compareVersions(v1: string, v2: string): number {
const v1Parts = v1.split('.').map(Number);
const v2Parts = v2.split('.').map(Number);
for (let i = 0; i < v1Parts.length; i++) {
if (v1Parts[i] > v2Parts[i]) {
return 1;
} else if (v1Parts[i] < v2Parts[i]) {
return -1;
}
}
return 0;
}
public async update(): Promise<Boolean> {
console.log("Updater invoked", { baseUrl: this.baseUrl, basePath: this.basePath });
const localPackage = JSON.parse(fs.readFileSync(path.join(this.basePath, './package.json'), 'utf-8'));
const remotePackage = await this.fetchJSON(`${this.baseUrl}/package.json`.toString());
console.log('Update check', { localVersion: localPackage.version, remoteVersion: remotePackage.version });
if (this.compareVersions(remotePackage.version, localPackage.version) === 1) {
for (const file of this.appFiles) {
const fileUrl = `${this.baseUrl}/${file}`;
const destination = path.join(this.basePath, file);
console.log(`Downloading '${fileUrl}' to '${destination}'.`);
await this.downloadFile(fileUrl.toString(), destination);
}
return true;
}
return false;
}
}

View file

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link href="./video-js.min.css" rel="stylesheet">
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<video id="video-player" class="video-js" controls preload="auto" data-setup='{}'>
<p class="vjs-no-js">
To view this video please enable JavaScript, and consider upgrading to a web browser that
<a href="https://videojs.com/html5-video-support/" target="_blank">supports HTML5 video
</a>
</p>
</video>
<script>window.HELP_IMPROVE_VIDEOJS = false;</script>
<script src="./video.min.js"></script>
<script src="./renderer.js"></script>
</body>
</html>

View file

@ -0,0 +1,13 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
toggleFullScreen: () => ipcRenderer.send('toggle-full-screen'),
exitFullScreen: () => ipcRenderer.send('exit-full-screen'),
sendPlaybackUpdate: (update) => ipcRenderer.send('send-playback-update', update),
sendVolumeUpdate: (update) => ipcRenderer.send('send-volume-update', update),
onPlay: (callback) => ipcRenderer.on("play", callback),
onPause: (callback) => ipcRenderer.on("pause", callback),
onResume: (callback) => ipcRenderer.on("resume", callback),
onSeek: (callback) => ipcRenderer.on("seek", callback),
onSetVolume: (callback) => ipcRenderer.on("setvolume", callback)
});

View file

@ -0,0 +1,162 @@
function toggleFullScreen(ev) {
window.electronAPI.toggleFullScreen();
}
const options = {
textTrackSettings: false
};
const player = videojs("video-player", options, function onPlayerReady() {
const fullScreenControls = document.getElementsByClassName("vjs-fullscreen-control");
for (let i = 0; i < fullScreenControls.length; i++) {
const node = fullScreenControls[i].cloneNode(true);
fullScreenControls[i].parentNode.replaceChild(node, fullScreenControls[i]);
fullScreenControls[i].onclick = toggleFullScreen;
fullScreenControls[i].ontap = toggleFullScreen;
}
});
player.on("pause", () => { window.electronAPI.sendPlaybackUpdate({ time: Math.round(player.currentTime()), state: 2 }) });
player.on("play", () => { window.electronAPI.sendPlaybackUpdate({ time: Math.round(player.currentTime()), state: 1 }) });
player.on("seeked", () => { window.electronAPI.sendPlaybackUpdate({ time: Math.round(player.currentTime()), state: player.paused() ? 2 : 1 }) });
player.on("volumechange", () => { window.electronAPI.sendVolumeUpdate({ volume: player.volume() }); });
window.electronAPI.onPlay((_event, value) => {
console.log("Handle play message renderer", value);
if (value.content) {
player.src({ type: value.container, src: `data:${value.container};base64,` + window.btoa(value.content) });
} else {
player.src({ type: value.container, src: value.url });
}
player.play();
if (value.time) {
player.currentTime(value.time);
}
});
window.electronAPI.onPause((_event) => {
console.log("Handle pause");
player.pause();
});
window.electronAPI.onResume((_event) => {
console.log("Handle resume");
player.play();
});
window.electronAPI.onSeek((_event, value) => {
console.log("Handle seek");
player.currentTime(value.time);
});
window.electronAPI.onSetVolume((_event, value) => {
console.log("Handle setVolume");
player.volume(Math.min(1.0, Math.max(0.0, value.volume)));
});
setInterval(() => {
window.electronAPI.sendPlaybackUpdate({ time: Math.round(player.currentTime()), state: player.paused() ? 2 : 1 });
}, 1000);
let mouseTimer = null;
let cursorVisible = true;
//Hide mouse cursor
function startMouseHideTimer() {
mouseTimer = window.setTimeout(() => {
mouseTimer = null;
document.body.style.cursor = "none";
cursorVisible = false;
}, 3000);
}
document.onmousemove = function() {
if (mouseTimer) {
window.clearTimeout(mouseTimer);
}
if (!cursorVisible) {
document.body.style.cursor = "default";
cursorVisible = true;
}
startMouseHideTimer();
};
startMouseHideTimer();
// Add the keydown event listener to the document
const skipInterval = 10;
const volumeIncrement = 0.1;
document.addEventListener('keydown', (event) => {
console.log("KeyDown", event);
switch (event.code) {
case 'F11':
window.electronAPI.toggleFullScreen();
event.preventDefault();
break;
case 'Escape':
window.electronAPI.exitFullScreen();
event.preventDefault();
break;
case 'ArrowLeft':
// Skip back
player.currentTime(Math.max(player.currentTime() - skipInterval, 0));
event.preventDefault();
break;
case 'ArrowRight':
// Skip forward
player.currentTime(Math.min(player.currentTime() + skipInterval, player.duration()));
event.preventDefault();
break;
case 'Space':
case 'Enter':
// Pause/Continue
if (player.paused()) {
player.play();
} else {
player.pause();
}
event.preventDefault();
break;
case 'KeyM':
// Mute toggle
player.muted(!player.muted());
break;
case 'ArrowUp':
// Volume up
player.volume(Math.min(player.volume() + volumeIncrement, 1));
break;
case 'ArrowDown':
// Volume down
player.volume(Math.max(player.volume() - volumeIncrement, 0));
break;
}
});
//Select subtitle track by default
player.ready(() => {
const textTracks = player.textTracks();
textTracks.addEventListener("change", function () {
console.log("Text tracks changed", textTracks);
for (let i = 0; i < textTracks.length; i++) {
if (textTracks[i].language === "en" && textTracks[i].mode !== "showing") {
textTracks[i].mode = "showing";
}
}
});
player.on('loadedmetadata', function () {
console.log("Metadata loaded", textTracks);
for (let i = 0; i < textTracks.length; i++) {
if (textTracks[i].language === "en" && textTracks[i].mode !== "showing") {
textTracks[i].mode = "showing";
}
}
});
});

View file

@ -0,0 +1,27 @@
html {
margin: 0;
padding: 0;
overflow: hidden;
}
body {
margin: 0;
padding: 0;
background-color: black;
color: white;
width: 100vw;
max-width: 100%;
height: 100vh;
max-height: 100%;
}
#video-player {
object-fit: contain;
width: 100%;
height: 100%;
}
*:focus {
outline: none;
box-shadow: none;
}

File diff suppressed because one or more lines are too long

26
receivers/electron/src/video.min.js vendored Normal file

File diff suppressed because one or more lines are too long