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:
commit
c8394f6a8e
99 changed files with 8173 additions and 0 deletions
4
receivers/electron/src/App.ts
Normal file
4
receivers/electron/src/App.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { app } from 'electron';
|
||||
import Main from './Main';
|
||||
|
||||
Main.main(app);
|
42
receivers/electron/src/DiscoveryService.ts
Normal file
42
receivers/electron/src/DiscoveryService.ts
Normal 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;
|
||||
}
|
||||
}
|
115
receivers/electron/src/FCastService.ts
Normal file
115
receivers/electron/src/FCastService.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
178
receivers/electron/src/FCastSession.ts
Normal file
178
receivers/electron/src/FCastSession.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
163
receivers/electron/src/Main.ts
Normal file
163
receivers/electron/src/Main.ts
Normal 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', () => { });
|
||||
}
|
||||
}
|
33
receivers/electron/src/Packets.ts
Normal file
33
receivers/electron/src/Packets.ts
Normal 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,
|
||||
) {}
|
||||
}
|
101
receivers/electron/src/Updater.ts
Normal file
101
receivers/electron/src/Updater.ts
Normal 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;
|
||||
}
|
||||
}
|
20
receivers/electron/src/index.html
Normal file
20
receivers/electron/src/index.html
Normal 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>
|
13
receivers/electron/src/preload.js
Normal file
13
receivers/electron/src/preload.js
Normal 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)
|
||||
});
|
162
receivers/electron/src/renderer.js
Normal file
162
receivers/electron/src/renderer.js
Normal 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";
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
27
receivers/electron/src/style.css
Normal file
27
receivers/electron/src/style.css
Normal 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;
|
||||
}
|
1
receivers/electron/src/video-js.min.css
vendored
Normal file
1
receivers/electron/src/video-js.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
26
receivers/electron/src/video.min.js
vendored
Normal file
26
receivers/electron/src/video.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue