1
0
Fork 0
mirror of https://gitlab.com/futo-org/fcast.git synced 2025-06-24 21:25:23 +00:00

Initial commit of WebOS receiver

This commit is contained in:
Michael Hollister 2024-12-09 00:56:55 -06:00
parent b7e304b987
commit 90e1f4de1a
118 changed files with 18279 additions and 1746 deletions

View file

@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,72 @@
Inter Variable Font
===================
This download contains Inter as both a variable font and static fonts.
Inter is a variable font with these axes:
slnt
wght
This means all the styles are contained in a single file:
Inter-VariableFont_slnt,wght.ttf
If your app fully supports variable fonts, you can now pick intermediate styles
that arent available as static fonts. Not all apps support variable fonts, and
in those cases you can use the static font files for Inter:
static/Inter-Thin.ttf
static/Inter-ExtraLight.ttf
static/Inter-Light.ttf
static/Inter-Regular.ttf
static/Inter-Medium.ttf
static/Inter-SemiBold.ttf
static/Inter-Bold.ttf
static/Inter-ExtraBold.ttf
static/Inter-Black.ttf
Get started
-----------
1. Install the font files you want to use
2. Use your app's font picker to view the font family and all the
available styles
Learn more about variable fonts
-------------------------------
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
https://variablefonts.typenetwork.com
https://medium.com/variable-fonts
In desktop apps
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
Online
https://developers.google.com/fonts/docs/getting_started
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
Installing fonts
MacOS: https://support.apple.com/en-us/HT201749
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
Android Apps
https://developers.google.com/fonts/docs/android
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
License
-------
Please read the full license text (OFL.txt) to understand the permissions,
restrictions and requirements for usage, redistribution, and modification.
You can use them in your products & projects print or digital,
commercial or otherwise.
This isn't legal advice, please consider consulting a lawyer and see the full
license for all details.

View file

@ -0,0 +1,93 @@
Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,71 @@
Outfit Variable Font
====================
This download contains Outfit as both a variable font and static fonts.
Outfit is a variable font with this axis:
wght
This means all the styles are contained in a single file:
Outfit-VariableFont_wght.ttf
If your app fully supports variable fonts, you can now pick intermediate styles
that arent available as static fonts. Not all apps support variable fonts, and
in those cases you can use the static font files for Outfit:
static/Outfit-Thin.ttf
static/Outfit-ExtraLight.ttf
static/Outfit-Light.ttf
static/Outfit-Regular.ttf
static/Outfit-Medium.ttf
static/Outfit-SemiBold.ttf
static/Outfit-Bold.ttf
static/Outfit-ExtraBold.ttf
static/Outfit-Black.ttf
Get started
-----------
1. Install the font files you want to use
2. Use your app's font picker to view the font family and all the
available styles
Learn more about variable fonts
-------------------------------
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
https://variablefonts.typenetwork.com
https://medium.com/variable-fonts
In desktop apps
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
Online
https://developers.google.com/fonts/docs/getting_started
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
Installing fonts
MacOS: https://support.apple.com/en-us/HT201749
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
Android Apps
https://developers.google.com/fonts/docs/android
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
License
-------
Please read the full license text (OFL.txt) to understand the permissions,
restrictions and requirements for usage, redistribution, and modification.
You can use them in your products & projects print or digital,
commercial or otherwise.
This isn't legal advice, please consider consulting a lawyer and see the full
license for all details.

View file

@ -0,0 +1,18 @@
@font-face {
font-family: InterVariable;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("Inter/InterVariable.woff2") format("woff2");
}
/* static fonts */
@font-face { font-family: "InterThin"; font-style: normal; font-weight: 100; font-display: swap; src: url("Inter/static/Inter-Thin.ttf") format("truetype"); }
@font-face { font-family: "InterExtraLight"; font-style: normal; font-weight: 200; font-display: swap; src: url("Inter/static/Inter-ExtraLight.ttf") format("truetype"); }
@font-face { font-family: "InterLight"; font-style: normal; font-weight: 300; font-display: swap; src: url("Inter/static/Inter-Light.ttf") format("truetype"); }
@font-face { font-family: "InterRegular"; font-style: normal; font-weight: 400; font-display: swap; src: url("Inter/static/Inter-Regular.ttf") format("truetype"); }
@font-face { font-family: "InterMedium"; font-style: normal; font-weight: 500; font-display: swap; src: url("Inter/static/Inter-Medium.ttf") format("truetype"); }
@font-face { font-family: "InterSemiBold"; font-style: normal; font-weight: 600; font-display: swap; src: url("Inter/static/Inter-SemiBold.ttf") format("truetype"); }
@font-face { font-family: "InterBold"; font-style: normal; font-weight: 700; font-display: swap; src: url("Inter/static/Inter-Bold.ttf") format("truetype"); }
@font-face { font-family: "InterExtraBold"; font-style: normal; font-weight: 800; font-display: swap; src: url("Inter/static/Inter-ExtraBold.ttf") format("truetype"); }
@font-face { font-family: "InterBlack"; font-style: normal; font-weight: 900; font-display: swap; src: url("Inter/static/Inter-Black.ttf") format("truetype"); }

View file

@ -0,0 +1,18 @@
@font-face {
font-family: Outfit;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("Outfit/Outfit-VariableFont_wght.ttf") format("truetype");
}
/* static fonts */
@font-face { font-family: "OutfitThin"; font-style: normal; font-weight: 100; font-display: swap; src: url("Outfit/static/Outfit-Thin.ttf") format("truetype"); }
@font-face { font-family: "OutfitExtraLight"; font-style: normal; font-weight: 200; font-display: swap; src: url("Outfit/static/Outfit-ExtraLight.ttf") format("truetype"); }
@font-face { font-family: "OutfitLight"; font-style: normal; font-weight: 300; font-display: swap; src: url("Outfit/static/Outfit-Light.ttf") format("truetype"); }
@font-face { font-family: "OutfitRegular"; font-style: normal; font-weight: 400; font-display: swap; src: url("Outfit/static/Outfit-Regular.ttf") format("truetype"); }
@font-face { font-family: "OutfitMedium"; font-style: normal; font-weight: 500; font-display: swap; src: url("Outfit/static/Outfit-Medium.ttf") format("truetype"); }
@font-face { font-family: "OutfitSemiBold"; font-style: normal; font-weight: 600; font-display: swap; src: url("Outfit/static/Outfit-SemiBold.ttf") format("truetype"); }
@font-face { font-family: "OutfitBold"; font-style: normal; font-weight: 700; font-display: swap; src: url("Outfit/static/Outfit-Bold.ttf") format("truetype"); }
@font-face { font-family: "OutfitExtraBold"; font-style: normal; font-weight: 800; font-display: swap; src: url("Outfit/static/Outfit-ExtraBold.ttf") format("truetype"); }
@font-face { font-family: "OutfitBlack"; font-style: normal; font-weight: 900; font-display: swap; src: url("Outfit/static/Outfit-Black.ttf") format("truetype"); }

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 566 B

After

Width:  |  Height:  |  Size: 566 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 566 B

After

Width:  |  Height:  |  Size: 566 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 566 B

After

Width:  |  Height:  |  Size: 566 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 564 B

After

Width:  |  Height:  |  Size: 564 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 456 B

After

Width:  |  Height:  |  Size: 456 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 382 B

After

Width:  |  Height:  |  Size: 382 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 183 B

After

Width:  |  Height:  |  Size: 183 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 181 B

After

Width:  |  Height:  |  Size: 181 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 720 B

After

Width:  |  Height:  |  Size: 720 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 718 B

After

Width:  |  Height:  |  Size: 718 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 850 B

After

Width:  |  Height:  |  Size: 850 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 1 KiB

Before After
Before After

View file

@ -0,0 +1,39 @@
import mdns from 'modules/mdns-js';
import { Main, getComputerName } from 'src/Main';
export class DiscoveryService {
private serviceTcp: any;
private serviceWs: any;
start() {
if (this.serviceTcp || this.serviceWs) {
return;
}
const name = `FCast-${getComputerName()}`;
// Cannot reference Main during static class initialization
// @ts-ignore
if (TARGET === 'webOS') {
console.log(`Discovery service started: ${name}`);
} else {
Main.logger.info(`Discovery service started: ${name}`);
}
this.serviceTcp = mdns.createAdvertisement(mdns.tcp('_fcast'), 46899, { name: name });
this.serviceTcp.start();
this.serviceWs = mdns.createAdvertisement(mdns.tcp('_fcast-ws'), 46898, { name: name });
this.serviceWs.start();
}
stop() {
if (this.serviceTcp) {
this.serviceTcp.stop();
this.serviceTcp = null;
}
if (this.serviceWs) {
this.serviceWs.stop();
this.serviceWs = null;
}
}
}

View file

@ -1,8 +1,8 @@
import * as net from 'net';
import * as log4js from "log4js";
import * as log4js from "modules/log4js";
import { EventEmitter } from 'node:events';
import { PlaybackErrorMessage, PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage, VersionMessage, VolumeUpdateMessage } from './Packets';
import { WebSocket } from 'ws';
import { PlaybackErrorMessage, PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage, VersionMessage, VolumeUpdateMessage } from 'common/Packets';
import { WebSocket } from 'modules/ws';
const logger = log4js.getLogger();
enum SessionState {
@ -205,4 +205,4 @@ export class FCastSession {
this.emitter.on("setvolume", (body: SetVolumeMessage) => { emitter.emit("setvolume", body) });
this.emitter.on("setspeed", (body: SetSpeedMessage) => { emitter.emit("setspeed", body) });
}
}
}

View file

@ -0,0 +1,124 @@
import { PlayMessage, PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage } from 'common/Packets';
import * as os from 'os';
import * as http from 'http';
import * as url from 'url';
import { AddressInfo } from 'modules/ws';
import { v4 as uuidv4 } from 'modules/uuid';
import { Main } from 'src/Main';
export class NetworkService {
static key: string = null;
static cert: string = null;
static proxyServer: http.Server;
static proxyServerAddress: AddressInfo;
static proxiedFiles: Map<string, { url: string, headers: { [key: string]: string } }> = new Map();
private static setupProxyServer(): Promise<void> {
return new Promise<void>((resolve, reject) => {
try {
Main.logger.info(`Proxy server starting`);
const port = 0;
NetworkService.proxyServer = http.createServer((req, res) => {
Main.logger.info(`Request received`);
const requestUrl = `http://${req.headers.host}${req.url}`;
const proxyInfo = NetworkService.proxiedFiles.get(requestUrl);
if (!proxyInfo) {
res.writeHead(404);
res.end('Not found');
return;
}
const omitHeaders = new Set([
'host',
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailers',
'transfer-encoding',
'upgrade'
]);
const filteredHeaders = Object.fromEntries(Object.entries(req.headers)
.filter(([key]) => !omitHeaders.has(key.toLowerCase()))
.map(([key, value]) => [key, Array.isArray(value) ? value.join(', ') : value]));
const parsedUrl = url.parse(proxyInfo.url);
const options: http.RequestOptions = {
... parsedUrl,
method: req.method,
headers: { ...filteredHeaders, ...proxyInfo.headers }
};
const proxyReq = http.request(options, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res, { end: true });
});
req.pipe(proxyReq, { end: true });
proxyReq.on('error', (e) => {
Main.logger.error(`Problem with request: ${e.message}`);
res.writeHead(500);
res.end();
});
});
NetworkService.proxyServer.on('error', e => {
reject(e);
});
NetworkService.proxyServer.listen(port, '127.0.0.1', () => {
NetworkService.proxyServerAddress = NetworkService.proxyServer.address() as AddressInfo;
Main.logger.info(`Proxy server running at http://127.0.0.1:${NetworkService.proxyServerAddress.port}/`);
resolve();
});
} catch (e) {
reject(e);
}
});
}
static streamingMediaTypes = [
"application/vnd.apple.mpegurl",
"application/x-mpegURL",
"application/dash+xml"
];
static async proxyPlayIfRequired(message: PlayMessage): Promise<PlayMessage> {
if (message.headers && message.url && !NetworkService.streamingMediaTypes.find(v => v === message.container.toLocaleLowerCase())) {
return { ...message, url: await NetworkService.proxyFile(message.url, message.headers) };
}
return message;
}
static async proxyFile(url: string, headers: { [key: string]: string }): Promise<string> {
if (!NetworkService.proxyServer) {
await NetworkService.setupProxyServer();
}
const proxiedUrl = `http://127.0.0.1:${NetworkService.proxyServerAddress.port}/${uuidv4()}`;
Main.logger.info("Proxied url", { proxiedUrl, url, headers });
NetworkService.proxiedFiles.set(proxiedUrl, { url: url, headers: headers });
return proxiedUrl;
}
static getAllIPv4Addresses() {
const interfaces = os.networkInterfaces();
const ipv4Addresses: string[] = [];
for (const interfaceName in interfaces) {
const addresses = interfaces[interfaceName];
if (!addresses) continue;
for (const addressInfo of addresses) {
if (addressInfo.family === 'IPv4' && !addressInfo.internal) {
ipv4Addresses.push(addressInfo.address);
}
}
}
return ipv4Addresses;
}
}

View file

@ -54,4 +54,4 @@ export class VersionMessage {
constructor(
public version: number,
) {}
}
}

View file

@ -1,8 +1,7 @@
import * as net from 'net';
import { FCastSession, Opcode } from './FCastSession';
import { FCastSession, Opcode } from 'common/FCastSession';
import { EventEmitter } from 'node:events';
import { dialog } from 'electron';
import Main from './Main';
import { Main, errorHandler } from 'src/Main';
export class TcpListenerService {
public static PORT = 46899;
@ -35,6 +34,7 @@ export class TcpListenerService {
}
send(opcode: number, message = null) {
// Main.logger.info(`Sending message ${JSON.stringify(message)}`);
this.sessions.forEach(session => {
try {
session.send(opcode, message);
@ -46,23 +46,7 @@ export class TcpListenerService {
}
private async handleServerError(err: NodeJS.ErrnoException) {
Main.logger.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);
}
errorHandler(err);
}
private handleConnection(socket: net.Socket) {
@ -100,4 +84,4 @@ export class TcpListenerService {
Main.logger.info('Failed to send version', e);
}
}
}
}

View file

@ -1,8 +1,7 @@
import { FCastSession, Opcode } from './FCastSession';
import { FCastSession, Opcode } from 'common/FCastSession';
import { EventEmitter } from 'node:events';
import { dialog } from 'electron';
import Main from './Main';
import { WebSocket, WebSocketServer } from 'ws';
import { WebSocket, WebSocketServer } from 'modules/ws';
import { Main, errorHandler } from 'src/Main';
export class WebSocketListenerService {
public static PORT = 46898;
@ -45,23 +44,7 @@ export class WebSocketListenerService {
}
private async handleServerError(err: NodeJS.ErrnoException) {
Main.logger.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);
}
errorHandler(err);
}
private handleConnection(socket: WebSocket) {
@ -105,4 +88,4 @@ export class WebSocketListenerService {
Main.logger.info('Failed to send version');
}
}
}
}

View file

@ -0,0 +1,95 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-require-imports */
/* eslint-disable @typescript-eslint/no-explicit-any */
export {};
declare global {
interface Window {
electronAPI: any;
webOS: any;
webOSDev: any;
targetAPI: any;
}
}
let deviceInfo: any;
// @ts-ignore
if (TARGET === 'electron') {
// @ts-ignore
const electronAPI = __non_webpack_require__('electron');
electronAPI.ipcRenderer.on("device-info", (_event, value) => {
deviceInfo = value;
})
electronAPI.contextBridge.exposeInMainWorld('targetAPI', {
onDeviceInfo: (callback: any) => electronAPI.ipcRenderer.on("device-info", callback),
getDeviceInfo: () => deviceInfo,
});
// @ts-ignore
} else if (TARGET === 'webOS') {
require('lib/webOSTVjs-1.2.10/webOSTV.js');
require('lib/webOSTVjs-1.2.10/webOSTV-dev.js');
const serviceId = 'com.futo.fcast.receiver.service';
let onDeviceInfoCb: any;
const getDeviceInfoService = window.webOS.service.request(`luna://${serviceId}/`, {
method:"getDeviceInfo",
parameters: {},
onSuccess: (message: any) => {
console.log(`Main: getDeviceInfo ${JSON.stringify(message)}`);
deviceInfo = message.value;
onDeviceInfoCb();
},
onFailure: (message: any) => {
console.error(`Main: getDeviceInfo ${JSON.stringify(message)}`);
},
// onComplete: (message) => {},
});
const playService = window.webOS.service.request(`luna://${serviceId}/`, {
method:"play",
parameters: {},
onSuccess: (message: any) => {
if (message.value.subscribed === true) {
console.log('Main: Registered play handler with service');
}
else {
console.log(`Main: Playing ${JSON.stringify(message)}`);
getDeviceInfoService.cancel();
playService.cancel();
}
},
onFailure: (message: any) => {
console.error(`Main: play ${JSON.stringify(message)}`);
},
subscribe: true,
resubscribe: true
});
window.targetAPI = {
onDeviceInfo: (callback: any) => onDeviceInfoCb = callback,
getDeviceInfo: () => deviceInfo,
};
document.addEventListener('webOSRelaunch', (args: any) => {
console.log(`Relaunching FCast Receiver with args: ${JSON.stringify(args)}`);
if (args.playData !== null) {
if (getDeviceInfoService !== undefined) {
getDeviceInfoService.cancel();
}
if (playService !== undefined) {
playService.cancel();
}
window.open('../player/index.html');
}
});
} else {
// @ts-ignore
console.log(`Attempting to run FCast player on unsupported target: ${TARGET}`);
}

View file

@ -0,0 +1,48 @@
import QRCode from 'modules/qrcode';
window.targetAPI.onDeviceInfo(renderIPsAndQRCode);
if(window.targetAPI.getDeviceInfo()) {
console.log("device info already present");
renderIPsAndQRCode();
}
function renderIPsAndQRCode() {
const value = window.targetAPI.getDeviceInfo();
console.log("device info", value);
const ipsElement = document.getElementById('ips');
if (ipsElement) {
ipsElement.innerHTML = `IPs<br>${value.addresses.join('<br>')}`;
}
const fcastConfig = {
name: value.name,
addresses: value.addresses,
services: [
{ port: 46899, type: 0 }, //TCP
{ port: 46898, type: 1 }, //WS
]
};
const json = JSON.stringify(fcastConfig);
let base64 = btoa(json);
base64 = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const url = `fcast://r/${base64}`;
console.log("qr", {json, url, base64});
const qrCodeElement = document.getElementById('qr-code');
QRCode.toCanvas(qrCodeElement, url, {
margin: 0,
width: 256,
color: {
dark : "#000000",
light : "#ffffff",
},
errorCorrectionLevel : "M",
},
(e) => {
console.log(`Error rendering QR Code: ${e}`)
});
}

View file

@ -0,0 +1,191 @@
body, html {
height: 100%;
margin: 0;
}
#main-container {
position: relative;
height: 100%;
overflow: hidden;
}
.video {
height: 100%;
width: 100%;
object-fit: cover;
}
.non-selectable {
user-select: none;
}
.card {
display: flex;
flex-direction: column;
text-align: center;
background-color: rgba(20, 20, 20, 0.5);
padding: 25px;
border-radius: 10px;
border: 1px solid #2E2E2E;
scrollbar-width: thin;
overflow: auto;
}
.card-title {
font-weight: 700;
line-height: 24px;
margin: 10px;
}
.card-title-separator {
height: 1px;
background: #2E2E2E;
margin-top: 3px;
margin-bottom: 3px;
}
#ui-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
color: white;
gap: 15vw;
font-family: InterVariable;
font-size: 20px;
font-style: normal;
font-weight: 400;
}
#title-container {
display: flex;
justify-content: center;
align-items: center;
}
#title-text {
font-family: Outfit;
font-size: 100px;
font-weight: 800;
text-align: center;
background-image: linear-gradient(180deg, #FFFFFF 5.9%, #D3D3D3 100%);
background-clip: text;
-webkit-background-clip:text;
-webkit-text-fill-color: transparent;
}
#title-icon {
width: 84px;
height: 84px;
background-image: url(../assets/icons/app/icon.svg);
background-size: cover;
margin-right: 15px;
}
#connection-status {
padding: 25px;
text-align: center;
}
#main-view {
padding: 25px;
}
#manual-connection-info {
font-weight: 700;
line-height: 24px;
margin: 10px;
}
#manual-connection-info-separator {
height: 1px;
background: #2E2E2E;
margin-top: 3px;
margin-bottom: 3px;
}
#qr-code {
display: flex;
margin: 20px auto;
flex-direction: column;
align-items: center;
padding: 20px;
background-color: white;
}
#scan-to-connect {
margin-top: 20px;
font-weight: bold;
}
#waiting-for-connection, #ips, #automatic-discovery {
margin-top: 20px;
}
#spinner {
padding: 20px;
}
#window-can-be-closed {
color: #666666;
position: absolute;
bottom: 0;
margin-bottom: 20px;
font-family: InterVariable;
font-size: 18px;
font-style: normal;
font-weight: 400;
}
.lds-ring {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 64px;
height: 64px;
margin: 8px;
border: 8px solid #fff;
border-radius: 50%;
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: #fff transparent transparent transparent;
}
.lds-ring div:nth-child(1) {
animation-delay: -0.45s;
}
.lds-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.lds-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes lds-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View file

@ -1,5 +1,5 @@
import dashjs from 'dashjs';
import Hls from 'hls.js';
import dashjs from 'modules/dashjs';
import Hls from 'modules/hls.js';
export enum PlayerType {
Html,

View file

@ -0,0 +1,240 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-require-imports */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage } from 'common/Packets';
export {};
declare global {
interface Window {
electronAPI: any;
webOSAPI: any;
webOS: any;
targetAPI: any;
}
}
// @ts-ignore
if (TARGET === 'electron') {
// @ts-ignore
const electronAPI = __non_webpack_require__('electron');
electronAPI.contextBridge.exposeInMainWorld('targetAPI', {
sendPlaybackError: (error: PlaybackErrorMessage) => electronAPI.ipcRenderer.send('send-playback-error', error),
sendPlaybackUpdate: (update: PlaybackUpdateMessage) => electronAPI.ipcRenderer.send('send-playback-update', update),
sendVolumeUpdate: (update: VolumeUpdateMessage) => electronAPI.ipcRenderer.send('send-volume-update', update),
onPlay: (callback: any) => electronAPI.ipcRenderer.on("play", callback),
onPause: (callback: any) => electronAPI.ipcRenderer.on("pause", callback),
onResume: (callback: any) => electronAPI.ipcRenderer.on("resume", callback),
onSeek: (callback: any) => electronAPI.ipcRenderer.on("seek", callback),
onSetVolume: (callback: any) => electronAPI.ipcRenderer.on("setvolume", callback),
onSetSpeed: (callback: any) => electronAPI.ipcRenderer.on("setspeed", callback)
});
// @ts-ignore
} else if (TARGET === 'webOS') {
require('lib/webOSTVjs-1.2.10/webOSTV.js');
require('lib/webOSTVjs-1.2.10/webOSTV-dev.js');
const serviceId = 'com.futo.fcast.receiver.service';
let onPlayCb, onPauseCb, onResumeCb;
let onSeekCb, onSetVolumeCb, onSetSpeedCb;
let playerWindowOpen = false;
const playService = window.webOS.service.request(`luna://${serviceId}/`, {
method:"play",
parameters: {},
onSuccess: (message: any) => {
console.log(JSON.stringify(message));
if (message.value.subscribed === true) {
console.log('Player: Registered play handler with service');
}
if (message.value.playData !== null) {
if (!playerWindowOpen) {
playerWindowOpen = true;
}
if (onPlayCb === undefined) {
window.webOSAPI.pendingPlay = message.value.playData;
}
else {
onPlayCb(null, message.value.playData);
}
}
},
onFailure: (message: any) => {
console.error(`Player: play ${JSON.stringify(message)}`);
},
subscribe: true,
resubscribe: true
});
const pauseService = window.webOS.service.request(`luna://${serviceId}/`, {
method:"pause",
parameters: {},
onSuccess: (message: any) => {
if (message.value.subscribed === true) {
console.log('Player: Registered pause handler with service');
}
else {
onPauseCb();
}
},
onFailure: (message: any) => {
console.error(`Player: pause ${JSON.stringify(message)}`);
},
subscribe: true,
resubscribe: true
});
const resumeService = window.webOS.service.request(`luna://${serviceId}/`, {
method:"resume",
parameters: {},
onSuccess: (message: any) => {
if (message.value.subscribed === true) {
console.log('Player: Registered resume handler with service');
}
else {
onResumeCb();
}
},
onFailure: (message: any) => {
console.error(`Player: resume ${JSON.stringify(message)}`);
},
subscribe: true,
resubscribe: true
});
const stopService = window.webOS.service.request(`luna://${serviceId}/`, {
method:"stop",
parameters: {},
onSuccess: (message: any) => {
if (message.value.subscribed === true) {
console.log('Player: Registered stop handler with service');
}
else {
playerWindowOpen = false;
playService.cancel();
pauseService.cancel();
resumeService.cancel();
stopService.cancel();
seekService.cancel();
setVolumeService.cancel();
setSpeedService.cancel();
// window.open('../main_window/index.html');
window.webOS.platformBack();
}
},
onFailure: (message: any) => {
console.error(`Player: stop ${JSON.stringify(message)}`);
},
subscribe: true,
resubscribe: true
});
const seekService = window.webOS.service.request(`luna://${serviceId}/`, {
method:"seek",
parameters: {},
onSuccess: (message: any) => {
if (message.value.subscribed === true) {
console.log('Player: Registered seek handler with service');
}
else {
onSeekCb(null, message.value);
}
},
onFailure: (message: any) => {
console.error(`Player: seek ${JSON.stringify(message)}`);
},
subscribe: true,
resubscribe: true
});
const setVolumeService = window.webOS.service.request(`luna://${serviceId}/`, {
method:"setvolume",
parameters: {},
onSuccess: (message: any) => {
if (message.value.subscribed === true) {
console.log('Player: Registered setvolume handler with service');
}
else {
onSetVolumeCb(null, message.value);
}
},
onFailure: (message: any) => {
console.error(`Player: setvolume ${JSON.stringify(message)}`);
},
subscribe: true,
resubscribe: true
});
const setSpeedService = window.webOS.service.request(`luna://${serviceId}/`, {
method:"setspeed",
parameters: {},
onSuccess: (message: any) => {
if (message.value.subscribed === true) {
console.log('Player: Registered setspeed handler with service');
}
else {
onSetSpeedCb(null, message.value);
}
},
onFailure: (message: any) => {
console.error(`Player: setspeed ${JSON.stringify(message)}`);
},
subscribe: true,
resubscribe: true
});
window.targetAPI = {
sendPlaybackError: (error: PlaybackErrorMessage) => {
window.webOS.service.request(`luna://${serviceId}/`, {
method: 'send_playback_error',
parameters: { error },
onSuccess: () => {},
onFailure: (message: any) => {
console.error(`Player: send_playback_error ${JSON.stringify(message)}`);
},
});
},
sendPlaybackUpdate: (update: PlaybackUpdateMessage) => {
window.webOS.service.request(`luna://${serviceId}/`, {
method: 'send_playback_update',
parameters: { update },
// onSuccess: (message: any) => {
// console.log(`Player: send_playback_update ${JSON.stringify(message)}`);
// },
onSuccess: () => {},
onFailure: (message: any) => {
console.error(`Player: send_playback_update ${JSON.stringify(message)}`);
},
});
},
sendVolumeUpdate: (update: VolumeUpdateMessage) => {
window.webOS.service.request(`luna://${serviceId}/`, {
method: 'send_volume_update',
parameters: { update },
onSuccess: () => {},
onFailure: (message: any) => {
console.error(`Player: send_volume_update ${JSON.stringify(message)}`);
},
});
},
onPlay: (callback: any) => { onPlayCb = callback; },
onPause: (callback: any) => { onPauseCb = callback; },
onResume: (callback: any) => { onResumeCb = callback; },
onSeek: (callback: any) => { onSeekCb = callback; },
onSetVolume: (callback: any) => { onSetVolumeCb = callback; },
onSetSpeed: (callback: any) => { onSetSpeedCb = callback; }
};
window.webOSAPI = {
pendingPlay: null
};
} else {
// @ts-ignore
console.log(`Attempting to run FCast player on unsupported target: ${TARGET}`);
}

View file

@ -0,0 +1,751 @@
import dashjs from 'modules/dashjs';
import Hls, { LevelLoadedData } from 'modules/hls.js';
import { PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage } from 'common/Packets';
import { Player, PlayerType } from './Player';
import { targetPlayerCtrlStateUpdate, targetKeyDownEventListener } from 'src/player/Renderer';
function formatDuration(duration: number) {
const totalSeconds = Math.floor(duration);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
const paddedMinutes = String(minutes).padStart(2, '0');
const paddedSeconds = String(seconds).padStart(2, '0');
if (hours > 0) {
return `${hours}:${paddedMinutes}:${paddedSeconds}`;
} else {
return `${paddedMinutes}:${paddedSeconds}`;
}
}
function sendPlaybackUpdate(updateState: number) {
const updateMessage = new PlaybackUpdateMessage(Date.now(), player.getCurrentTime(), player.getDuration(), updateState, player.getPlaybackRate());
if (updateMessage.generationTime > lastPlayerUpdateGenerationTime) {
lastPlayerUpdateGenerationTime = updateMessage.generationTime;
window.targetAPI.sendPlaybackUpdate(updateMessage);
}
};
function onPlayerLoad(value: PlayMessage, currentPlaybackRate?: number, currentVolume?: number) {
playerCtrlStateUpdate(PlayerControlEvent.Load);
// Subtitles break when seeking post stream initialization for the DASH player.
// Its currently done on player initialization.
if (player.playerType === PlayerType.Hls || player.playerType === PlayerType.Html) {
if (value.time) {
player.setCurrentTime(value.time);
}
}
if (value.speed) {
player.setPlaybackRate(value.speed);
} else if (currentPlaybackRate) {
player.setPlaybackRate(currentPlaybackRate);
} else {
player.setPlaybackRate(1.0);
}
playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate);
if (currentVolume) {
volumeChangeHandler(currentVolume);
}
else {
// FCast PlayMessage does not contain volume field and could result in the receiver
// getting out-of-sync with the sender on 1st playback.
volumeChangeHandler(1.0);
window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: 1.0 });
}
player.play();
}
// HTML elements
const videoElement = document.getElementById("videoPlayer") as HTMLVideoElement;
const videoCaptions = document.getElementById("videoCaptions") as HTMLDivElement;
const playerControls = document.getElementById("controls");
const playerCtrlAction = document.getElementById("action");
const playerCtrlVolume = document.getElementById("volume");
const playerCtrlProgressBar = document.getElementById("progressBar");
const playerCtrlProgressBarBuffer = document.getElementById("progressBarBuffer");
const playerCtrlProgressBarProgress = document.getElementById("progressBarProgress");
const playerCtrlProgressBarPosition = document.getElementById("progressBarPosition");
const playerCtrlProgressBarHandle = document.getElementById("progressBarHandle");
const PlayerCtrlProgressBarInteractiveArea = document.getElementById("progressBarInteractiveArea");
const playerCtrlVolumeBar = document.getElementById("volumeBar");
const playerCtrlVolumeBarProgress = document.getElementById("volumeBarProgress");
const playerCtrlVolumeBarHandle = document.getElementById("volumeBarHandle");
const playerCtrlVolumeBarInteractiveArea = document.getElementById("volumeBarInteractiveArea");
const playerCtrlLiveBadge = document.getElementById("liveBadge");
const playerCtrlPosition = document.getElementById("position");
const playerCtrlDuration = document.getElementById("duration");
const playerCtrlCaptions = document.getElementById("captions");
const playerCtrlSpeed = document.getElementById("speed");
const playerCtrlSpeedMenu = document.getElementById("speedMenu");
let playerCtrlSpeedMenuShown = false;
const playbackRates = ["0.25", "0.50", "0.75", "1.00", "1.25", "1.50", "1.75", "2.00"];
const playbackUpdateInterval = 1.0;
const livePositionDelta = 5.0;
const livePositionWindow = livePositionDelta * 4;
let player: Player;
let playerPrevTime: number = 0;
let lastPlayerUpdateGenerationTime = 0;
let isLive = false;
let isLivePosition = false;
function onPlay(_event, value: PlayMessage) {
console.log("Handle play message renderer", JSON.stringify(value));
const currentVolume = player ? player.getVolume() : null;
const currentPlaybackRate = player ? player.getPlaybackRate() : null;
playerPrevTime = 0;
lastPlayerUpdateGenerationTime = 0;
isLive = false;
isLivePosition = false;
if (player) {
if (player.getSource() === value.url) {
if (value.time) {
if (Math.abs(value.time - player.getCurrentTime()) < 5000) {
console.warn(`Skipped changing video URL because URL and time is (nearly) unchanged: ${value.url}, ${player.getSource()}, ${formatDuration(value.time)}, ${formatDuration(player.getCurrentTime())}`);
} else {
console.info(`Skipped changing video URL because URL is the same, but time was changed, seeking instead: ${value.url}, ${player.getSource()}, ${formatDuration(value.time)}, ${formatDuration(player.getCurrentTime())}`);
player.setCurrentTime(value.time);
}
}
return;
}
player.destroy();
}
if ((value.url || value.content) && value.container && videoElement) {
if (value.container === 'application/dash+xml') {
console.log("Loading dash player");
const dashPlayer = dashjs.MediaPlayer().create();
player = new Player(PlayerType.Dash, dashPlayer);
dashPlayer.extend("RequestModifier", () => {
return {
modifyRequestHeader: function (xhr) {
if (value.headers) {
for (const [key, val] of Object.entries(value.headers)) {
xhr.setRequestHeader(key, val);
}
}
return xhr;
}
};
}, true);
// Player event handlers
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PLAYING, () => { sendPlaybackUpdate(1); playerCtrlStateUpdate(PlayerControlEvent.Play); });
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PAUSED, () => { sendPlaybackUpdate(2); playerCtrlStateUpdate(PlayerControlEvent.Pause); });
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ENDED, () => { sendPlaybackUpdate(0) });
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_TIME_UPDATED, () => {
playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate);
if (Math.abs(dashPlayer.time() - playerPrevTime) >= playbackUpdateInterval) {
sendPlaybackUpdate(dashPlayer.isPaused() ? 2 : 1);
playerPrevTime = dashPlayer.time();
}
});
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_RATE_CHANGED, () => { sendPlaybackUpdate(dashPlayer.isPaused() ? 2 : 1) });
// Buffering UI update when paused
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PROGRESS, () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); });
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_VOLUME_CHANGED, () => {
const updateVolume = dashPlayer.isMuted() ? 0 : dashPlayer.getVolume();
playerCtrlStateUpdate(PlayerControlEvent.VolumeChange);
window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume });
});
dashPlayer.on(dashjs.MediaPlayer.events.ERROR, (data) => { window.targetAPI.sendPlaybackError({
message: `DashJS ERROR: ${JSON.stringify(data)}`
})});
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ERROR, (data) => { window.targetAPI.sendPlaybackError({
message: `DashJS PLAYBACK_ERROR: ${JSON.stringify(data)}`
})});
dashPlayer.on(dashjs.MediaPlayer.events.STREAM_INITIALIZED, () => { onPlayerLoad(value, currentPlaybackRate, currentVolume); });
dashPlayer.on(dashjs.MediaPlayer.events.CUE_ENTER, (e: any) => {
const subtitle = document.createElement("p")
subtitle.setAttribute("id", "subtitle-" + e.cueID)
subtitle.textContent = e.text;
videoCaptions.appendChild(subtitle);
});
dashPlayer.on(dashjs.MediaPlayer.events.CUE_EXIT, (e: any) => {
document.getElementById("subtitle-" + e.cueID)?.remove();
});
dashPlayer.updateSettings({
// debug: {
// logLevel: dashjs.LogLevel.LOG_LEVEL_INFO
// },
streaming: {
text: {
dispatchForManualRendering: true
}
}
});
if (value.content) {
dashPlayer.initialize(videoElement, `data:${value.container};base64,` + window.btoa(value.content), true, value.time);
// dashPlayer.initialize(videoElement, "https://dash.akamaized.net/akamai/test/caption_test/ElephantsDream/elephants_dream_480p_heaac5_1_https.mpd", true);
} else {
// value.url = 'https://dash.akamaized.net/akamai/bbb_30fps/bbb_30fps.mpd';
dashPlayer.initialize(videoElement, value.url, true, value.time);
}
} else if ((value.container === 'application/vnd.apple.mpegurl' || value.container === 'application/x-mpegURL') && !videoElement.canPlayType(value.container)) {
console.log("Loading hls player");
const config = {
xhrSetup: function (xhr: XMLHttpRequest) {
if (value.headers) {
for (const [key, val] of Object.entries(value.headers)) {
xhr.setRequestHeader(key, val);
}
}
},
};
const hlsPlayer = new Hls(config);
hlsPlayer.on(Hls.Events.ERROR, (eventName, data) => {
window.targetAPI.sendPlaybackError({
message: `HLS player error: ${JSON.stringify(data)}`
});
});
hlsPlayer.on(Hls.Events.LEVEL_LOADED, (eventName, level: LevelLoadedData) => {
isLive = level.details.live;
isLivePosition = isLive ? true : false;
});
player = new Player(PlayerType.Hls, videoElement, hlsPlayer);
// value.url = "https://devstreaming-cdn.apple.com/videos/streaming/examples/adv_dv_atmos/main.m3u8?ref=developerinsider.co";
hlsPlayer.loadSource(value.url);
hlsPlayer.attachMedia(videoElement);
// hlsPlayer.subtitleDisplay = true;
} else {
console.log("Loading html player");
player = new Player(PlayerType.Html, videoElement);
videoElement.src = value.url;
videoElement.load();
}
// Player event handlers
if (player.playerType === PlayerType.Hls || player.playerType === PlayerType.Html) {
videoElement.onplay = () => { sendPlaybackUpdate(1); playerCtrlStateUpdate(PlayerControlEvent.Play); };
videoElement.onpause = () => { sendPlaybackUpdate(2); playerCtrlStateUpdate(PlayerControlEvent.Pause); };
videoElement.onended = () => { sendPlaybackUpdate(0) };
videoElement.ontimeupdate = () => {
playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate);
if (Math.abs(videoElement.currentTime - playerPrevTime) >= playbackUpdateInterval) {
sendPlaybackUpdate(videoElement.paused ? 2 : 1);
playerPrevTime = videoElement.currentTime;
}
};
// Buffering UI update when paused
videoElement.onprogress = () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); };
videoElement.onratechange = () => { sendPlaybackUpdate(videoElement.paused ? 2 : 1) };
videoElement.onvolumechange = () => {
const updateVolume = videoElement.muted ? 0 : videoElement.volume;
playerCtrlStateUpdate(PlayerControlEvent.VolumeChange);
window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume });
};
videoElement.onerror = (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) => {
console.error("Player error", {source, lineno, colno, error});
};
videoElement.onloadedmetadata = () => { onPlayerLoad(value, currentPlaybackRate, currentVolume); };
}
}
// Sender generated event handlers
window.targetAPI.onPause(() => { player.pause(); });
window.targetAPI.onResume(() => { player.play(); });
window.targetAPI.onSeek((_event, value: SeekMessage) => { player.setCurrentTime(value.time); });
window.targetAPI.onSetVolume((_event, value: SetVolumeMessage) => { volumeChangeHandler(value.volume); });
window.targetAPI.onSetSpeed((_event, value: SetSpeedMessage) => { player.setPlaybackRate(value.speed); playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate); });
};
window.targetAPI.onPlay(onPlay);
let scrubbing = false;
let volumeChanging = false;
enum PlayerControlEvent {
Load,
Pause,
Play,
VolumeChange,
TimeUpdate,
UiFadeOut,
UiFadeIn,
SetCaptions,
ToggleSpeedMenu,
SetPlaybackRate,
ToggleFullscreen,
ExitFullscreen,
}
// UI update handlers
function playerCtrlStateUpdate(event: PlayerControlEvent) {
switch (event) {
case PlayerControlEvent.Load: {
playerCtrlProgressBarBuffer.setAttribute("style", "width: 0px");
playerCtrlProgressBarProgress.setAttribute("style", "width: 0px");
playerCtrlProgressBarHandle.setAttribute("style", `left: ${playerCtrlProgressBar.offsetLeft}px`);
const volume = Math.round(player.getVolume() * playerCtrlVolumeBar.offsetWidth);
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume + 8}px`);
if (isLive) {
playerCtrlLiveBadge.setAttribute("style", "display: block");
playerCtrlPosition.setAttribute("style", "display: none");
playerCtrlDuration.setAttribute("style", "display: none");
}
else {
playerCtrlLiveBadge.setAttribute("style", "display: none");
playerCtrlPosition.setAttribute("style", "display: block");
playerCtrlDuration.setAttribute("style", "display: block");
playerCtrlPosition.textContent = formatDuration(player.getCurrentTime());
playerCtrlDuration.innerHTML = `/&nbsp&nbsp${formatDuration(player.getDuration())}`;
}
if (player.isCaptionsSupported()) {
playerCtrlCaptions.setAttribute("style", "display: block");
videoCaptions.setAttribute("style", "display: block");
}
else {
playerCtrlCaptions.setAttribute("style", "display: none");
videoCaptions.setAttribute("style", "display: none");
player.enableCaptions(false);
}
playerCtrlStateUpdate(PlayerControlEvent.SetCaptions);
break;
}
case PlayerControlEvent.Pause:
playerCtrlAction.setAttribute("class", "play");
stopUiHideTimer();
break;
case PlayerControlEvent.Play:
playerCtrlAction.setAttribute("class", "pause");
startUiHideTimer();
break;
case PlayerControlEvent.VolumeChange: {
// console.log(`VolumeChange: isMute ${player.isMuted()}, volume: ${player.getVolume()}`);
const volume = Math.round(player.getVolume() * playerCtrlVolumeBar.offsetWidth);
if (player.isMuted()) {
playerCtrlVolume.setAttribute("class", "mute");
playerCtrlVolumeBarProgress.setAttribute("style", `width: 0px`);
playerCtrlVolumeBarHandle.setAttribute("style", `left: 0px`);
}
else if (player.getVolume() >= 0.5) {
playerCtrlVolume.setAttribute("class", "volume_high");
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume}px`);
} else {
playerCtrlVolume.setAttribute("class", "volume_low");
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume}px`);
}
break;
}
case PlayerControlEvent.TimeUpdate: {
// console.log(`TimeUpdate: Position: ${player.getCurrentTime()}, Duration: ${player.getDuration()}`);
if (isLive) {
if (isLivePosition && player.getDuration() - player.getCurrentTime() > livePositionWindow) {
isLivePosition = false;
playerCtrlLiveBadge.setAttribute("style", `background-color: #595959`);
}
else if (!isLivePosition && player.getDuration() - player.getCurrentTime() <= livePositionWindow) {
isLivePosition = true;
playerCtrlLiveBadge.setAttribute("style", `background-color: red`);
}
}
if (isLivePosition) {
playerCtrlProgressBarProgress.setAttribute("style", `width: ${playerCtrlProgressBar.offsetWidth}px`);
playerCtrlProgressBarHandle.setAttribute("style", `left: ${playerCtrlProgressBar.offsetWidth + playerCtrlProgressBar.offsetLeft}px`);
}
else {
const buffer = Math.round((player.getBufferLength() / player.getDuration()) * playerCtrlProgressBar.offsetWidth);
const progress = Math.round((player.getCurrentTime() / player.getDuration()) * playerCtrlProgressBar.offsetWidth);
const handle = progress + playerCtrlProgressBar.offsetLeft;
playerCtrlProgressBarBuffer.setAttribute("style", `width: ${buffer}px`);
playerCtrlProgressBarProgress.setAttribute("style", `width: ${progress}px`);
playerCtrlProgressBarHandle.setAttribute("style", `left: ${handle}px`);
playerCtrlPosition.textContent = formatDuration(player.getCurrentTime());
}
break;
}
case PlayerControlEvent.UiFadeOut:
document.body.style.cursor = "none";
playerControls.setAttribute("style", "opacity: 0");
if (player.isCaptionsEnabled()) {
videoCaptions.setAttribute("style", "display: block; bottom: 75px;");
} else {
videoCaptions.setAttribute("style", "display: none; bottom: 75px;");
}
break;
case PlayerControlEvent.UiFadeIn:
document.body.style.cursor = "default";
playerControls.setAttribute("style", "opacity: 1");
if (player.isCaptionsEnabled()) {
videoCaptions.setAttribute("style", "display: block; bottom: 160px;");
} else {
videoCaptions.setAttribute("style", "display: none; bottom: 160px;");
}
break;
case PlayerControlEvent.SetCaptions:
if (player.isCaptionsEnabled()) {
playerCtrlCaptions.setAttribute("class", "captions_on");
videoCaptions.setAttribute("style", "display: block");
} else {
playerCtrlCaptions.setAttribute("class", "captions_off");
videoCaptions.setAttribute("style", "display: none");
}
break;
case PlayerControlEvent.ToggleSpeedMenu: {
if (playerCtrlSpeedMenuShown) {
playerCtrlSpeedMenu.setAttribute("style", "display: none");
} else {
playerCtrlSpeedMenu.setAttribute("style", "display: block");
}
playerCtrlSpeedMenuShown = !playerCtrlSpeedMenuShown;
break;
}
case PlayerControlEvent.SetPlaybackRate: {
const rate = player.getPlaybackRate().toFixed(2);
const entryElement = document.getElementById(`speedMenuEntry_${rate}_enabled`);
playbackRates.forEach(r => {
const entry = document.getElementById(`speedMenuEntry_${r}_enabled`);
entry.setAttribute("style", "opacity: 0");
});
// Ignore updating GUI for custom rates
if (entryElement !== null) {
entryElement.setAttribute("style", "opacity: 1");
}
break;
}
default:
targetPlayerCtrlStateUpdate(event);
break;
}
}
function scrubbingMouseUIHandler(e: MouseEvent) {
const progressBarOffset = e.offsetX - 8;
const progressBarWidth = PlayerCtrlProgressBarInteractiveArea.offsetWidth - 16;
let time = isLive ? Math.round((1 - (progressBarOffset / progressBarWidth)) * player.getDuration()) : Math.round((progressBarOffset / progressBarWidth) * player.getDuration());
time = Math.min(player.getDuration(), Math.max(0.0, time));
if (scrubbing && isLive && e.buttons === 1) {
isLivePosition = false;
playerCtrlLiveBadge.setAttribute("style", `background-color: #595959`);
}
const livePrefix = isLive && Math.floor(time) !== 0 ? "-" : "";
playerCtrlProgressBarPosition.textContent = isLive ? `${livePrefix}${formatDuration(time)}` : formatDuration(time);
let offset = e.offsetX - (playerCtrlProgressBarPosition.offsetWidth / 2);
offset = Math.min(PlayerCtrlProgressBarInteractiveArea.offsetWidth - (playerCtrlProgressBarPosition.offsetWidth / 1), Math.max(8, offset));
playerCtrlProgressBarPosition.setAttribute("style", `display: block; left: ${offset}px`);
}
// Receiver generated event handlers
playerCtrlAction.onclick = () => {
if (player.isPaused()) {
player.play();
} else {
player.pause();
}
};
playerCtrlVolume.onclick = () => { player.setMute(!player.isMuted()); };
PlayerCtrlProgressBarInteractiveArea.onmousedown = (e: MouseEvent) => { scrubbing = true; scrubbingMouseHandler(e) };
PlayerCtrlProgressBarInteractiveArea.onmouseup = () => { scrubbing = false; };
PlayerCtrlProgressBarInteractiveArea.onmouseenter = (e: MouseEvent) => {
if (e.buttons === 0) {
volumeChanging = false;
}
scrubbingMouseUIHandler(e);
};
PlayerCtrlProgressBarInteractiveArea.onmouseleave = () => { playerCtrlProgressBarPosition.setAttribute("style", "display: none"); };
PlayerCtrlProgressBarInteractiveArea.onmousemove = (e: MouseEvent) => { scrubbingMouseHandler(e) };
function scrubbingMouseHandler(e: MouseEvent) {
const progressBarOffset = e.offsetX - 8;
const progressBarWidth = PlayerCtrlProgressBarInteractiveArea.offsetWidth - 16;
let time = Math.round((progressBarOffset / progressBarWidth) * player.getDuration());
time = Math.min(player.getDuration(), Math.max(0.0, time));
if (scrubbing && e.buttons === 1) {
player.setCurrentTime(time);
}
scrubbingMouseUIHandler(e);
}
playerCtrlVolumeBarInteractiveArea.onmousedown = (e: MouseEvent) => { volumeChanging = true; volumeChangeMouseHandler(e) };
playerCtrlVolumeBarInteractiveArea.onmouseup = () => { volumeChanging = false; };
playerCtrlVolumeBarInteractiveArea.onmouseenter = (e: MouseEvent) => {
if (e.buttons === 0) {
scrubbing = false;
}
};
playerCtrlVolumeBarInteractiveArea.onmousemove = (e: MouseEvent) => { volumeChangeMouseHandler(e) };
playerCtrlVolumeBarInteractiveArea.onwheel = (e: WheelEvent) => {
const delta = -e.deltaY;
if (delta > 0 ) {
volumeChangeHandler(Math.min(player.getVolume() + volumeIncrement, 1));
} else if (delta < 0) {
volumeChangeHandler(Math.max(player.getVolume() - volumeIncrement, 0));
}
};
function volumeChangeMouseHandler(e: MouseEvent) {
if (volumeChanging && e.buttons === 1) {
const volumeBarOffsetX = e.offsetX - 8;
const volumeBarWidth = playerCtrlVolumeBarInteractiveArea.offsetWidth - 16;
const volume = volumeBarOffsetX / volumeBarWidth;
volumeChangeHandler(volume);
}
}
function volumeChangeHandler(volume: number) {
if (!player.isMuted() && volume <= 0) {
player.setMute(true);
}
else if (player.isMuted() && volume > 0) {
player.setMute(false);
}
player.setVolume(volume);
}
playerCtrlLiveBadge.onclick = () => { setLivePosition(); };
function setLivePosition() {
if (!isLivePosition) {
isLivePosition = true;
player.setCurrentTime(player.getDuration() - livePositionDelta);
playerCtrlLiveBadge.setAttribute("style", `background-color: red`);
if (player.isPaused()) {
player.play();
}
}
}
playerCtrlCaptions.onclick = () => { player.enableCaptions(!player.isCaptionsEnabled()); playerCtrlStateUpdate(PlayerControlEvent.SetCaptions); };
playerCtrlSpeed.onclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu); };
playbackRates.forEach(r => {
const entry = document.getElementById(`speedMenuEntry_${r}`);
entry.onclick = () => {
player.setPlaybackRate(parseFloat(r));
playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate);
playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu);
};
});
videoElement.onclick = () => {
if (!playerCtrlSpeedMenuShown) {
if (player.isPaused()) {
player.play();
} else {
player.pause();
}
}
};
// Component hiding
let uiHideTimer = null;
let uiVisible = true;
function startUiHideTimer() {
if (uiHideTimer === null) {
uiHideTimer = window.setTimeout(() => {
uiHideTimer = null;
uiVisible = false;
playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut);
}, 3000);
}
}
function stopUiHideTimer() {
if (uiHideTimer) {
window.clearTimeout(uiHideTimer);
uiHideTimer = null;
}
if (!uiVisible) {
uiVisible = true;
playerCtrlStateUpdate(PlayerControlEvent.UiFadeIn);
}
}
document.onmouseout = () => {
if (uiHideTimer) {
window.clearTimeout(uiHideTimer);
uiHideTimer = null;
}
uiVisible = false;
playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut);
}
document.onmousemove = () => {
stopUiHideTimer();
if (player && !player.isPaused()) {
startUiHideTimer();
}
};
window.onresize = () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); };
// Listener for hiding speed menu when clicking outside element
document.addEventListener('click', (event: MouseEvent) => {
const node = event.target as Node;
if (playerCtrlSpeedMenuShown && !playerCtrlSpeed.contains(node) && !playerCtrlSpeedMenu.contains(node)){
playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu);
}
});
// Add the keydown event listener to the document
const skipInterval = 10;
const volumeIncrement = 0.1;
function keyDownEventListener(event: any) {
// console.log("KeyDown", event);
switch (event.code) {
case 'KeyF':
case 'F11':
playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen);
event.preventDefault();
break;
case 'Escape':
playerCtrlStateUpdate(PlayerControlEvent.ExitFullscreen);
event.preventDefault();
break;
case 'ArrowLeft':
// Skip back
player.setCurrentTime(Math.max(player.getCurrentTime() - skipInterval, 0));
event.preventDefault();
break;
case 'ArrowRight':
// Skip forward
if (!isLivePosition) {
player.setCurrentTime(Math.min(player.getCurrentTime() + skipInterval, player.getDuration()));
}
event.preventDefault();
break;
case "Home":
player.setCurrentTime(0);
event.preventDefault();
break;
case "End":
if (isLive) {
setLivePosition();
}
else {
player.setCurrentTime(player.getDuration());
}
event.preventDefault();
break;
case 'KeyK':
case 'Space':
case 'Enter':
// Pause/Continue
if (player.isPaused()) {
player.play();
} else {
player.pause();
}
event.preventDefault();
break;
case 'KeyM':
// Mute toggle
player.setMute(!player.isMuted());
break;
case 'ArrowUp':
// Volume up
volumeChangeHandler(Math.min(player.getVolume() + volumeIncrement, 1));
break;
case 'ArrowDown':
// Volume down
volumeChangeHandler(Math.max(player.getVolume() - volumeIncrement, 0));
break;
default:
targetKeyDownEventListener(event);
break;
}
}
document.addEventListener('keydown', keyDownEventListener);
export {
videoElement,
PlayerControlEvent,
onPlay,
playerCtrlStateUpdate,
};

View file

@ -0,0 +1,468 @@
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%;
}
#videoPlayer {
object-fit: contain;
width: 100%;
height: 100%;
}
*:focus {
outline: none;
box-shadow: none;
}
.container {
position: absolute;
bottom: 0px;
/* height: 100%; */
height: 120px;
width: 100%;
/* background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 100%); */
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.0) 35%);
background-size: 100% 300px;
background-repeat: no-repeat;
background-position: bottom;
opacity: 1;
transition: opacity 0.1s ease-in-out;
}
.volumeContainer {
position: relative;
height: 24px;
width: 92px;
flex-shrink: 0;
user-select: none;
}
.volumeBar {
position: absolute;
/* left: 12px; */
left: 8px;
top: 10px;
height: 4px;
/* width: 72px; */
width: 76px;
background-color: #999999;
border-radius: 3px;
pointer-events: none;
}
.volumeBarInteractiveArea {
position: absolute;
left: 0px;
/* left: 8px; */
top: 0px;
height: 24px;
width: 92px;
/* width: 84px; */
cursor: pointer;
}
.volumeBarHandle {
position: absolute;
left: 84px;
top: 4px;
width: 16px;
height: 16px;
/* background-color: #ffffff; */
background-color: #c9c9c9;
box-shadow: 0px 32px 64px 0px rgba(0, 0, 0, 0.56), 0px 2px 21px 0px rgba(0, 0, 0, 0.55);
border-radius: 50%;
pointer-events: none;
z-index: 10;
}
.volumeBarProgress {
position: absolute;
/* left: 12px; */
left: 8px;
top: 10px;
height: 4px;
width: 76px;
/* background-color: #ffffff; */
background-color: #c9c9c9;
border-radius: 3px;
pointer-events: none;
}
.progressBarContainer {
position: absolute;
bottom: 60px;
left: 16px;
right: 16px;
height: 4px;
padding-top: 10px;
padding-bottom: 10px;
border-radius: 3px;
cursor: pointer;
user-select: none;
}
.progressBarInteractiveArea {
position: absolute;
/* bottom: 60px; */
/* left: 24px; */
/* right: 24px; */
height: 4px;
width: 100%;
left: 0px;
bottom: 0px;
padding-top: 10px;
padding-bottom: 10px;
border-radius: 3px;
cursor: pointer;
z-index: 999;
}
.progressBarChapterContainer {
position: absolute;
bottom: 73px;
left: 24px;
right: 24px;
height: 4px;
border-radius: 3px;
cursor: pointer;
}
.progressBar {
/* position: absolute; */
position: relative;
/* bottom: 70px; */
/* left: 24px; */
/* right: 24px; */
left: 8px;
width: calc(100% - 16px);
height: 4px;
background-color: #99999945;
border-radius: 3px;
pointer-events: none;
}
.progressBarBuffer {
/* position: absolute; */
position: relative;
/* bottom: 70px; */
/* left: 24px; */
left: 8px;
bottom: 4px;
height: 4px;
background-color: #D9D9D945;
border-radius: 3px;
pointer-events: none;
}
.progressBarProgress {
/* position: absolute; */
position: relative;
/* bottom: 70px; */
/* left: 24px; */
left: 8px;
bottom: 8px;
height: 4px;
width: 0px;
background-color: #019BE7;
border-radius: 3px;
pointer-events: none;
}
.progressBarPosition {
display: none;
position: absolute;
bottom: 25px;
padding: 2px 5px;
font-family: InterVariable;
font-size: 16px;
font-style: normal;
font-weight: 400;
border-radius: 3px;
background-color: rgba(0, 0, 0, 0.5);
}
.progressBarHandle {
position: absolute;
/* bottom: 70px; */
bottom: 10px;
width: 20px;
height: 20px;
margin-left: -8px;
margin-bottom: -8px;
background-color: #019BE7;
border-radius: 50%;
pointer-events: none;
z-index: 10;
}
.positionContainer {
display: flex;
flex-direction: row;
flex-grow: 1;
align-items: center;
font-family: InterVariable;
font-size: 16px;
font-style: normal;
font-weight: 400;
user-select: text;
}
.position {
margin-right: 10px;
vertical-align: bottom;
color: #c9c9c9;
}
.duration {
opacity: 0.6;
color: #c9c9c9;
}
.liveBadge {
background-color: red;
/* margin-top: -2px; */
/* padding: 5px 5px; */
padding: 2px 5px;
border-radius: 4px;
/* margin-left: 10px; */
margin-right: 10px;
cursor: pointer;
}
.play {
width: 24px;
height: 24px;
cursor: pointer;
flex-shrink: 0;
background-image: url("../assets/icons/player/icon24_play.svg");
transition: background-image 0.1s ease-in-out;
}
.play:hover {
background-image: url("../assets/icons/player/icon24_play_active.svg");
}
.pause {
width: 24px;
height: 24px;
cursor: pointer;
flex-shrink: 0;
background-image: url("../assets/icons/player/icon24_pause.svg");
transition: background-image 0.1s ease-in-out;
}
.pause:hover {
background-image: url("../assets/icons/player/icon24_pause_active.svg");
}
.volume_high {
width: 24px;
height: 24px;
cursor: pointer;
flex-shrink: 0;
background-image: url("../assets/icons/player/icon24_volume_more_50pct.svg");
transition: background-image 0.1s ease-in-out;
}
.volume_high:hover {
background-image: url("../assets/icons/player/icon24_volume_more_50pct_active.svg");
}
.volume_low {
width: 24px;
height: 24px;
cursor: pointer;
flex-shrink: 0;
background-image: url("../assets/icons/player/icon24_volume_less_50pct.svg");
transition: background-image 0.1s ease-in-out;
}
.volume_low:hover {
background-image: url("../assets/icons/player/icon24_volume_less_50pct_active.svg");
}
.mute {
width: 24px;
height: 24px;
cursor: pointer;
flex-shrink: 0;
background-image: url("../assets/icons/player/icon24_mute.svg");
transition: background-image 0.1s ease-in-out;
}
.mute:hover {
background-image: url("../assets/icons/player/icon24_mute_active.svg");
}
.speed {
width: 24px;
height: 24px;
cursor: pointer;
background-image: url("../assets/icons/player/icon24_speed.svg");
transition: background-image 0.1s ease-in-out;
}
.speed:hover {
background-image: url("../assets/icons/player/icon24_speed_active.svg");
}
.captions_off {
width: 24px;
height: 24px;
cursor: pointer;
background-image: url("../assets/icons/player/icon24_cc_off.svg");
transition: background-image 0.1s ease-in-out;
}
.captions_off:hover {
background-image: url("../assets/icons/player/icon24_cc_off_active.svg");
}
.captions_on {
width: 24px;
height: 24px;
cursor: pointer;
background-image: url("../assets/icons/player/icon24_cc_on.svg");
transition: background-image 0.1s ease-in-out;
}
.captions_on:hover {
background-image: url("../assets/icons/player/icon24_cc_on_active.svg");
}
.leftButtonContainer {
position: absolute;
bottom: 24px;
left: 24px;
height: 24px;
/* width: calc(50% - 24px); */
right: 160px;
display: flex;
flex-direction: row;
align-items: center;
gap: 24px;
overflow: hidden;
user-select: none;
}
.buttonContainer {
position: absolute;
bottom: 24px;
right: 24px;
height: 24px;
/* width: calc(50% - 24px); */
align-items: center;
overflow: hidden;
display: flex;
flex-direction: row-reverse;
gap: 24px;
}
.captionsContainer {
/* display: none; */
position: relative;
/* top: -200px; */
bottom: 160px;
margin: auto;
text-align: center;
font-family: InterVariable;
font-size: 28px;
font-style: normal;
font-weight: 400;
background-color: rgba(0, 0, 0, 0.5);
padding: 0px 5px;
width: fit-content;
transition: bottom 0.2s ease-in-out;
}
.speedMenu {
position: absolute;
bottom: 80px;
right: 60px;
height: calc(55vh);
max-height: 368px;
background-color: #141414;
padding: 12px;
border-radius: 10px;
border: 1px solid #2E2E2E;
scrollbar-width: thin;
overflow: auto;
font-family: InterVariable;
font-size: 16px;
font-style: normal;
font-weight: 400;
box-shadow: 0px 1.852px 3.148px 0px rgba(0, 0, 0, 0.06), 0px 8.148px 6.519px 0px rgba(0, 0, 0, 0.10), 0px 20px 13px 0px rgba(0, 0, 0, 0.13), 0px 38.519px 25.481px 0px rgba(0, 0, 0, 0.15), 0px 64.815px 46.852px 0px rgba(0, 0, 0, 0.19), 0px 100px 80px 0px rgba(0, 0, 0, 0.25);
}
.speedMenuTitle {
font-weight: 700;
line-height: 24px;
margin: 10px;
}
.speedMenuEntry {
display: flex;
padding: 10px 15px;
}
.speedMenuEntry:hover {
cursor: pointer;
background-color: rgba(255, 255, 255, 0.1);
}
.speedMenuSeparator {
height: 1px;
background: #2E2E2E;
margin-top: 3px;
margin-bottom: 3px;
}
.speedMenuEntryEnabled {
width: 20px;
height: 20px;
margin-right: 10px;
background-image: url("../assets/icons/player/icon24_check_thin.svg");
background-size: cover;
opacity: 0;
}

View file

@ -1,7 +1,7 @@
FROM node:22.10.0-bookworm
RUN dpkg --add-architecture i386
RUN apt update && apt install -y zip dpkg fakeroot rpm wget p7zip-full unzip rsync jq awscli
RUN apt update && apt install -y zip dpkg fakeroot rpm wget p7zip-full unzip jq awscli
RUN wget https://github.com/ebourg/jsign/releases/download/6.0/jsign_6.0_all.deb
RUN apt install -y ./jsign_6.0_all.deb

View file

@ -1,7 +0,0 @@
@font-face {
font-family: InterVariable;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("InterVariable.woff2") format("woff2");
}

View file

@ -1,7 +0,0 @@
@font-face {
font-family: Outfit;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("Outfit-VariableFont_wght.ttf") format("truetype");
}

View file

@ -19,9 +19,9 @@
"https": "^1.0.0",
"log4js": "^6.9.1",
"qrcode": "^1.5.3",
"url": "^0.11.3",
"uuid": "^9.0.1",
"ws": "^8.14.2",
"url": "^0.11.4",
"uuid": "^11.0.3",
"ws": "^8.18.0",
"yargs": "^17.7.2"
},
"devDependencies": {
@ -44,6 +44,7 @@
"@types/workerpool": "^6.1.1",
"@types/ws": "^8.5.10",
"@types/yargs": "^17.0.33",
"copy-webpack-plugin": "^12.0.2",
"electron": "^32.2.1",
"eslint": "^9.10.0",
"globals": "^15.9.0",
@ -2504,6 +2505,19 @@
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@sindresorhus/merge-streams": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
"integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@sinonjs/commons": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz",
@ -3399,6 +3413,48 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv-formats/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/ajv-keywords": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
@ -4436,6 +4492,88 @@
"dev": true,
"license": "MIT"
},
"node_modules/copy-webpack-plugin": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz",
"integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-glob": "^3.3.2",
"glob-parent": "^6.0.1",
"globby": "^14.0.0",
"normalize-path": "^3.0.0",
"schema-utils": "^4.2.0",
"serialize-javascript": "^6.0.2"
},
"engines": {
"node": ">= 18.12.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"webpack": "^5.1.0"
}
},
"node_modules/copy-webpack-plugin/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/copy-webpack-plugin/node_modules/ajv-keywords": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3"
},
"peerDependencies": {
"ajv": "^8.8.2"
}
},
"node_modules/copy-webpack-plugin/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/copy-webpack-plugin/node_modules/schema-utils": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz",
"integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.9",
"ajv": "^8.9.0",
"ajv-formats": "^2.1.1",
"ajv-keywords": "^5.1.0"
},
"engines": {
"node": ">= 12.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/create-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
@ -6052,6 +6190,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz",
"integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/fastest-levenshtein": {
"version": "1.0.16",
"resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
@ -6665,6 +6810,53 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/globby": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/globby/-/globby-14.0.2.tgz",
"integrity": "sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^2.1.0",
"fast-glob": "^3.3.2",
"ignore": "^5.2.4",
"path-type": "^5.0.0",
"slash": "^5.1.0",
"unicorn-magic": "^0.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globby/node_modules/path-type": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz",
"integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globby/node_modules/slash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
"integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
@ -10256,6 +10448,16 @@
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
@ -11619,6 +11821,19 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/unicorn-magic": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz",
"integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/unique-filename": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz",
@ -11748,16 +11963,16 @@
"license": "MIT"
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz",
"integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/v8-to-istanbul": {

View file

@ -6,7 +6,7 @@
"author": "FUTO",
"license": "MIT",
"scripts": {
"build": "rm -rf dist/ && webpack --config ./webpack.config.js && rsync -r src/player/* dist/player --exclude *.ts && rsync -r src/main/* dist/main --exclude *.ts && cp assets/icons/app/icon.ico dist/ && cp assets/icons/app/icon.png dist/ && cp assets/icons/app/icon512.png dist/",
"build": "rm -rf dist/ && webpack --config ./webpack.config.js",
"start": "electron-forge start",
"test": "jest",
"package": "electron-forge package",
@ -32,6 +32,7 @@
"@types/workerpool": "^6.1.1",
"@types/ws": "^8.5.10",
"@types/yargs": "^17.0.33",
"copy-webpack-plugin": "^12.0.2",
"electron": "^32.2.1",
"eslint": "^9.10.0",
"globals": "^15.9.0",
@ -55,9 +56,9 @@
"https": "^1.0.0",
"log4js": "^6.9.1",
"qrcode": "^1.5.3",
"url": "^0.11.3",
"uuid": "^9.0.1",
"ws": "^8.14.2",
"url": "^0.11.4",
"uuid": "^11.0.3",
"ws": "^8.18.0",
"yargs": "^17.7.2"
}
}

View file

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

View file

@ -1,74 +0,0 @@
import mdns from 'mdns-js';
import * as log4js from "log4js";
const cp = require('child_process');
const os = require('os');
const logger = log4js.getLogger();
export class DiscoveryService {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private serviceTcp: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private serviceWs: 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": {
let hostname: string;
// Some distro's don't work with `os.hostname()`, but work with `hostnamectl` and vice versa...
try {
hostname = os.hostname();
}
catch (err) {
logger.warn('Error fetching hostname, trying different method...');
logger.warn(err);
try {
hostname = cp.execSync("hostnamectl hostname").toString().trim();
}
catch (err2) {
logger.warn('Error fetching hostname again, using generic name...');
logger.warn(err2);
hostname = 'linux device';
}
}
return hostname;
}
default:
return os.hostname();
}
}
start() {
if (this.serviceTcp || this.serviceWs) {
return;
}
const name = `FCast-${DiscoveryService.getComputerName()}`;
logger.info("Discovery service started.", name);
this.serviceTcp = mdns.createAdvertisement(mdns.tcp('_fcast'), 46899, { name: name });
this.serviceTcp.start();
this.serviceWs = mdns.createAdvertisement(mdns.tcp('_fcast-ws'), 46898, { name: name });
this.serviceWs.start();
}
stop() {
if (this.serviceTcp) {
this.serviceTcp.stop();
this.serviceTcp = null;
}
if (this.serviceWs) {
this.serviceWs.stop();
this.serviceWs = null;
}
}
}

View file

@ -1,21 +1,19 @@
import { BrowserWindow, ipcMain, IpcMainEvent, nativeImage, Tray, Menu, dialog } from 'electron';
import { TcpListenerService } from './TcpListenerService';
import { PlayMessage, PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage } from './Packets';
import { DiscoveryService } from './DiscoveryService';
import { PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage } from 'common/Packets';
import { DiscoveryService } from 'common/DiscoveryService';
import { TcpListenerService } from 'common/TcpListenerService';
import { WebSocketListenerService } from 'common/WebSocketListenerService';
import { NetworkService } from 'common/NetworkService';
import { Opcode } from 'common/FCastSession';
import { Updater } from './Updater';
import { WebSocketListenerService } from './WebSocketListenerService';
import { Opcode } from './FCastSession';
import * as os from 'os';
import * as path from 'path';
import * as http from 'http';
import * as url from 'url';
import * as log4js from "log4js";
import { AddressInfo } from 'ws';
import { v4 as uuidv4 } from 'uuid';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
const cp = require('child_process');
export default class Main {
export class Main {
static shouldOpenMainWindow = true;
static startFullscreen = false;
static playerWindow: Electron.BrowserWindow;
@ -25,11 +23,6 @@ export default class Main {
static webSocketListenerService: WebSocketListenerService;
static discoveryService: DiscoveryService;
static tray: Tray;
static key: string = null;
static cert: string = null;
static proxyServer: http.Server;
static proxyServerAddress: AddressInfo;
static proxiedFiles: Map<string, { url: string, headers: { [key: string]: string } }> = new Map();
static logger: log4js.Logger;
private static toggleMainWindow() {
@ -80,7 +73,7 @@ export default class Main {
}
private static createTray() {
const icon = (process.platform === 'win32') ? path.join(__dirname, 'icon.ico') : path.join(__dirname, 'icon.png');
const icon = (process.platform === 'win32') ? path.join(__dirname, 'assets/icons/app/icon.ico') : path.join(__dirname, 'assets/icons/app/icon.png');
const trayicon = nativeImage.createFromPath(icon)
const tray = new Tray(trayicon.resize({ width: 16 }));
const contextMenu = Menu.buildFromTemplate([
@ -175,13 +168,13 @@ export default class Main {
Main.playerWindow.loadFile(path.join(__dirname, 'player/index.html'));
Main.playerWindow.on('ready-to-show', async () => {
Main.playerWindow?.webContents?.send("play", await Main.proxyPlayIfRequired(message));
Main.playerWindow?.webContents?.send("play", await NetworkService.proxyPlayIfRequired(message));
});
Main.playerWindow.on('closed', () => {
Main.playerWindow = null;
});
} else {
Main.playerWindow?.webContents?.send("play", await Main.proxyPlayIfRequired(message));
Main.playerWindow?.webContents?.send("play", await NetworkService.proxyPlayIfRequired(message));
}
});
@ -283,116 +276,6 @@ export default class Main {
}
}
private static setupProxyServer(): Promise<void> {
return new Promise<void>((resolve, reject) => {
try {
Main.logger.info(`Proxy server starting`);
const port = 0;
Main.proxyServer = http.createServer((req, res) => {
Main.logger.info(`Request received`);
const requestUrl = `http://${req.headers.host}${req.url}`;
const proxyInfo = Main.proxiedFiles.get(requestUrl);
if (!proxyInfo) {
res.writeHead(404);
res.end('Not found');
return;
}
const omitHeaders = new Set([
'host',
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailers',
'transfer-encoding',
'upgrade'
]);
const filteredHeaders = Object.fromEntries(Object.entries(req.headers)
.filter(([key]) => !omitHeaders.has(key.toLowerCase()))
.map(([key, value]) => [key, Array.isArray(value) ? value.join(', ') : value]));
const parsedUrl = url.parse(proxyInfo.url);
const options: http.RequestOptions = {
... parsedUrl,
method: req.method,
headers: { ...filteredHeaders, ...proxyInfo.headers }
};
const proxyReq = http.request(options, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res, { end: true });
});
req.pipe(proxyReq, { end: true });
proxyReq.on('error', (e) => {
Main.logger.error(`Problem with request: ${e.message}`);
res.writeHead(500);
res.end();
});
});
Main.proxyServer.on('error', e => {
reject(e);
});
Main.proxyServer.listen(port, '127.0.0.1', () => {
Main.proxyServerAddress = Main.proxyServer.address() as AddressInfo;
Main.logger.info(`Proxy server running at http://127.0.0.1:${Main.proxyServerAddress.port}/`);
resolve();
});
} catch (e) {
reject(e);
}
});
}
static streamingMediaTypes = [
"application/vnd.apple.mpegurl",
"application/x-mpegURL",
"application/dash+xml"
];
static async proxyPlayIfRequired(message: PlayMessage): Promise<PlayMessage> {
if (message.headers && message.url && !Main.streamingMediaTypes.find(v => v === message.container.toLocaleLowerCase())) {
return { ...message, url: await Main.proxyFile(message.url, message.headers) };
}
return message;
}
static async proxyFile(url: string, headers: { [key: string]: string }): Promise<string> {
if (!Main.proxyServer) {
await Main.setupProxyServer();
}
const proxiedUrl = `http://127.0.0.1:${Main.proxyServerAddress.port}/${uuidv4()}`;
Main.logger.info("Proxied url", { proxiedUrl, url, headers });
Main.proxiedFiles.set(proxiedUrl, { url: url, headers: headers });
return proxiedUrl;
}
static getAllIPv4Addresses() {
const interfaces = os.networkInterfaces();
const ipv4Addresses: string[] = [];
for (const interfaceName in interfaces) {
const addresses = interfaces[interfaceName];
if (!addresses) continue;
for (const addressInfo of addresses) {
if (addressInfo.family === 'IPv4' && !addressInfo.internal) {
ipv4Addresses.push(addressInfo.address);
}
}
}
return ipv4Addresses;
}
static openMainWindow() {
if (Main.mainWindow) {
Main.mainWindow.focus();
@ -419,7 +302,7 @@ export default class Main {
Main.mainWindow.show();
Main.mainWindow.on('ready-to-show', () => {
Main.mainWindow.webContents.send("device-info", {name: os.hostname(), addresses: Main.getAllIPv4Addresses()});
Main.mainWindow.webContents.send("device-info", {name: os.hostname(), addresses: NetworkService.getAllIPv4Addresses()});
});
}
@ -471,3 +354,59 @@ export default class Main {
}
}
}
export function getComputerName() {
switch (process.platform) {
case "win32":
return process.env.COMPUTERNAME;
case "darwin":
return cp.execSync("scutil --get ComputerName").toString().trim();
case "linux": {
let hostname: string;
// Some distro's don't work with `os.hostname()`, but work with `hostnamectl` and vice versa...
try {
hostname = os.hostname();
}
catch (err) {
Main.logger.warn('Error fetching hostname, trying different method...');
Main.logger.warn(err);
try {
hostname = cp.execSync("hostnamectl hostname").toString().trim();
}
catch (err2) {
Main.logger.warn('Error fetching hostname again, using generic name...');
Main.logger.warn(err2);
hostname = 'linux device';
}
}
return hostname;
}
default:
return os.hostname();
}
}
export async function errorHandler(err: NodeJS.ErrnoException) {
Main.logger.error("Application 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);
}
}

View file

@ -1,17 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { contextBridge, ipcRenderer } from 'electron';
let deviceInfo;
ipcRenderer.on("device-info", (_event, value) => {
deviceInfo = value;
})
import 'common/main/Preload';
contextBridge.exposeInMainWorld('electronAPI', {
updaterProgress: () => ipcRenderer.invoke('updater-progress'),
onDeviceInfo: (callback) => ipcRenderer.on("device-info", callback),
onUpdateAvailable: (callback) => ipcRenderer.on("update-available", callback),
onUpdateAvailable: (callback: any) => ipcRenderer.on("update-available", callback),
sendDownloadRequest: () => ipcRenderer.send('send-download-request'),
onDownloadComplete: (callback) => ipcRenderer.on("download-complete", callback),
onDownloadFailed: (callback) => ipcRenderer.on("download-failed", callback),
onDownloadComplete: (callback: any) => ipcRenderer.on("download-complete", callback),
onDownloadFailed: (callback: any) => ipcRenderer.on("download-failed", callback),
sendRestartRequest: () => ipcRenderer.send('send-restart-request'),
getDeviceInfo: () => deviceInfo,
});

View file

@ -1,4 +1,4 @@
import QRCode from 'qrcode';
import 'common/main/Renderer';
const updateView = document.getElementById("update-view");
const updateViewTitle = document.getElementById("update-view-title");
@ -10,51 +10,6 @@ const progressBar = document.getElementById("progress-bar");
const progressBarProgress = document.getElementById("progress-bar-progress");
let updaterProgressUIUpdateTimer = null;
window.electronAPI.onDeviceInfo(renderIPsAndQRCode);
if(window.electronAPI.getDeviceInfo()) {
console.log("device info already present");
renderIPsAndQRCode();
}
function renderIPsAndQRCode() {
const value = window.electronAPI.getDeviceInfo();
console.log("device info", value);
const ipsElement = document.getElementById('ips');
if (ipsElement) {
ipsElement.innerHTML = `IPs<br>${value.addresses.join('<br>')}`;
}
const fcastConfig = {
name: value.name,
addresses: value.addresses,
services: [
{ port: 46899, type: 0 }, //TCP
{ port: 46898, type: 1 }, //WS
]
};
const json = JSON.stringify(fcastConfig);
let base64 = btoa(json);
base64 = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const url = `fcast://r/${base64}`;
console.log("qr", {json, url, base64});
const qrCodeElement = document.getElementById('qr-code');
QRCode.toCanvas(qrCodeElement, url, {
margin: 0,
width: 256,
color: {
dark : "#000000",
light : "#ffffff",
},
errorCorrectionLevel : "M",
},
(e) => {
console.log(`Error rendering QR Code: ${e}`)
});
}
window.electronAPI.onUpdateAvailable(() => {
console.log(`Received UpdateAvailable event`);

View file

@ -1,16 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="../../assets/fonts/outfit.css" />
<link rel="stylesheet" href="../../assets/fonts/inter.css" />
<link rel="stylesheet" href="./style.css" />
<title>FCast Receiver</title>
<meta charset="UTF-8">
<link rel="stylesheet" href="../assets/fonts/outfit.css" />
<link rel="stylesheet" href="../assets/fonts/inter.css" />
<link rel="stylesheet" href="./common.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="main-container">
<video id="video-player" class="video" autoplay loop>
<source src="../../assets/video/background.mp4" type="video/mp4">
<source src="../assets/video/background.mp4" type="video/mp4">
</video>
<div id="ui-container">
<div id="overlay">

View file

@ -1,50 +1,3 @@
body, html {
height: 100%;
margin: 0;
}
#main-container {
position: relative;
height: 100%;
overflow: hidden;
}
.video {
height: 100%;
width: 100%;
object-fit: cover;
}
.non-selectable {
user-select: none;
}
.card {
display: flex;
flex-direction: column;
text-align: center;
background-color: rgba(20, 20, 20, 0.5);
padding: 25px;
border-radius: 10px;
border: 1px solid #2E2E2E;
scrollbar-width: thin;
overflow: auto;
}
.card-title {
font-weight: 700;
line-height: 24px;
margin: 10px;
}
.card-title-separator {
height: 1px;
background: #2E2E2E;
margin-top: 3px;
margin-bottom: 3px;
}
.button {
display: inline-block;
align-items: center;
@ -83,98 +36,6 @@ body, html {
background: #3E3E3E;
}
#ui-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
color: white;
gap: 15vw;
font-family: InterVariable;
font-size: 20px;
font-style: normal;
font-weight: 400;
}
#title-container {
display: flex;
justify-content: center;
align-items: center;
}
#title-text {
font-family: Outfit;
font-size: 100px;
font-weight: 800;
text-align: center;
background-image: linear-gradient(180deg, #FFFFFF 5.9%, #D3D3D3 100%);
background-clip: text;
-webkit-text-fill-color: transparent;
}
#title-icon {
width: 84px;
height: 84px;
background-image: url(../../assets/icons/app/icon.svg);
background-size: cover;
margin-right: 15px;
}
#connection-status {
padding: 25px;
text-align: center;
}
#main-view {
padding: 25px;
}
#manual-connection-info {
font-weight: 700;
line-height: 24px;
margin: 10px;
}
#manual-connection-info-separator {
height: 1px;
background: #2E2E2E;
margin-top: 3px;
margin-bottom: 3px;
}
#qr-code {
display: flex;
margin: 20px auto;
flex-direction: column;
align-items: center;
padding: 20px;
background-color: white;
}
#scan-to-connect {
margin-top: 20px;
font-weight: bold;
}
#waiting-for-connection, #ips, #automatic-discovery {
margin-top: 20px;
}
#update-text {
margin-top: 20px;
width: 320px;
@ -188,10 +49,6 @@ body, html {
display: none;
}
#spinner {
padding: 20px;
}
#update-button-container {
display: flex;
flex-direction: row;
@ -225,51 +82,3 @@ body, html {
background-position: 0 0;
}
}
#window-can-be-closed {
color: #666666;
position: absolute;
bottom: 0;
margin-bottom: 20px;
font-family: InterVariable;
font-size: 18px;
font-style: normal;
font-weight: 400;
}
.lds-ring {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 64px;
height: 64px;
margin: 8px;
border: 8px solid #fff;
border-radius: 50%;
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: #fff transparent transparent transparent;
}
.lds-ring div:nth-child(1) {
animation-delay: -0.45s;
}
.lds-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.lds-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes lds-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View file

@ -1,24 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { contextBridge, ipcRenderer } from 'electron';
import { PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage } from '../Packets';
declare global {
interface Window {
electronAPI: any;
}
}
import 'common/player/Preload';
contextBridge.exposeInMainWorld('electronAPI', {
isFullScreen: () => ipcRenderer.invoke('is-full-screen'),
toggleFullScreen: () => ipcRenderer.send('toggle-full-screen'),
exitFullScreen: () => ipcRenderer.send('exit-full-screen'),
sendPlaybackError: (error: PlaybackErrorMessage) => ipcRenderer.send('send-playback-error', error),
sendPlaybackUpdate: (update: PlaybackUpdateMessage) => ipcRenderer.send('send-playback-update', update),
sendVolumeUpdate: (update: VolumeUpdateMessage) => ipcRenderer.send('send-volume-update', update),
onPlay: (callback: any) => ipcRenderer.on("play", callback),
onPause: (callback: any) => ipcRenderer.on("pause", callback),
onResume: (callback: any) => ipcRenderer.on("resume", callback),
onSeek: (callback: any) => ipcRenderer.on("seek", callback),
onSetVolume: (callback: any) => ipcRenderer.on("setvolume", callback),
onSetSpeed: (callback: any) => ipcRenderer.on("setspeed", callback)
});

View file

@ -1,483 +1,11 @@
import dashjs from 'dashjs';
import Hls, { LevelLoadedData } from 'hls.js';
import { PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage } from '../Packets';
import { Player, PlayerType } from './Player';
import { videoElement, PlayerControlEvent, playerCtrlStateUpdate } from 'common/player/Renderer';
function formatDuration(duration: number) {
const totalSeconds = Math.floor(duration);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
const paddedMinutes = String(minutes).padStart(2, '0');
const paddedSeconds = String(seconds).padStart(2, '0');
if (hours > 0) {
return `${hours}:${paddedMinutes}:${paddedSeconds}`;
} else {
return `${paddedMinutes}:${paddedSeconds}`;
}
}
function sendPlaybackUpdate(updateState: number) {
const updateMessage = new PlaybackUpdateMessage(Date.now(), player.getCurrentTime(), player.getDuration(), updateState, player.getPlaybackRate());
if (updateMessage.generationTime > lastPlayerUpdateGenerationTime) {
lastPlayerUpdateGenerationTime = updateMessage.generationTime;
window.electronAPI.sendPlaybackUpdate(updateMessage);
}
};
function onPlayerLoad(value: PlayMessage, currentPlaybackRate?: number, currentVolume?: number) {
playerCtrlStateUpdate(PlayerControlEvent.Load);
// Subtitles break when seeking post stream initialization for the DASH player.
// Its currently done on player initialization.
if (player.playerType === PlayerType.Hls || player.playerType === PlayerType.Html) {
if (value.time) {
player.setCurrentTime(value.time);
}
}
if (value.speed) {
player.setPlaybackRate(value.speed);
} else if (currentPlaybackRate) {
player.setPlaybackRate(currentPlaybackRate);
} else {
player.setPlaybackRate(1.0);
}
playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate);
if (currentVolume) {
volumeChangeHandler(currentVolume);
}
else {
// FCast PlayMessage does not contain volume field and could result in the receiver
// getting out-of-sync with the sender on 1st playback.
volumeChangeHandler(1.0);
window.electronAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: 1.0 });
}
player.play();
}
// HTML elements
const videoElement = document.getElementById("videoPlayer") as HTMLVideoElement;
const videoCaptions = document.getElementById("videoCaptions") as HTMLDivElement;
const playerControls = document.getElementById("controls");
const playerCtrlAction = document.getElementById("action");
const playerCtrlVolume = document.getElementById("volume");
const playerCtrlProgressBar = document.getElementById("progressBar");
const playerCtrlProgressBarBuffer = document.getElementById("progressBarBuffer");
const playerCtrlProgressBarProgress = document.getElementById("progressBarProgress");
const playerCtrlProgressBarPosition = document.getElementById("progressBarPosition");
const playerCtrlProgressBarHandle = document.getElementById("progressBarHandle");
const PlayerCtrlProgressBarInteractiveArea = document.getElementById("progressBarInteractiveArea");
const playerCtrlVolumeBar = document.getElementById("volumeBar");
const playerCtrlVolumeBarProgress = document.getElementById("volumeBarProgress");
const playerCtrlVolumeBarHandle = document.getElementById("volumeBarHandle");
const playerCtrlVolumeBarInteractiveArea = document.getElementById("volumeBarInteractiveArea");
const playerCtrlLiveBadge = document.getElementById("liveBadge");
const playerCtrlPosition = document.getElementById("position");
const playerCtrlDuration = document.getElementById("duration");
const playerCtrlCaptions = document.getElementById("captions");
const playerCtrlSpeed = document.getElementById("speed");
const playerCtrlFullscreen = document.getElementById("fullscreen");
playerCtrlFullscreen.onclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); };
videoElement.ondblclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); };
const playerCtrlSpeedMenu = document.getElementById("speedMenu");
let playerCtrlSpeedMenuShown = false;
const playbackRates = ["0.25", "0.50", "0.75", "1.00", "1.25", "1.50", "1.75", "2.00"];
const playbackUpdateInterval = 1.0;
const livePositionDelta = 5.0;
const livePositionWindow = livePositionDelta * 4;
let player: Player;
let playerPrevTime: number = 0;
let lastPlayerUpdateGenerationTime = 0;
let isLive = false;
let isLivePosition = false;
window.electronAPI.onPlay((_event, value: PlayMessage) => {
console.log("Handle play message renderer", JSON.stringify(value));
const currentVolume = player ? player.getVolume() : null;
const currentPlaybackRate = player ? player.getPlaybackRate() : null;
playerPrevTime = 0;
lastPlayerUpdateGenerationTime = 0;
isLive = false;
isLivePosition = false;
if (player) {
if (player.getSource() === value.url) {
if (value.time) {
if (Math.abs(value.time - player.getCurrentTime()) < 5000) {
console.warn(`Skipped changing video URL because URL and time is (nearly) unchanged: ${value.url}, ${player.getSource()}, ${formatDuration(value.time)}, ${formatDuration(player.getCurrentTime())}`);
} else {
console.info(`Skipped changing video URL because URL is the same, but time was changed, seeking instead: ${value.url}, ${player.getSource()}, ${formatDuration(value.time)}, ${formatDuration(player.getCurrentTime())}`);
player.setCurrentTime(value.time);
}
}
return;
}
player.destroy();
}
if ((value.url || value.content) && value.container && videoElement) {
if (value.container === 'application/dash+xml') {
console.log("Loading dash player");
const dashPlayer = dashjs.MediaPlayer().create();
player = new Player(PlayerType.Dash, dashPlayer);
dashPlayer.extend("RequestModifier", () => {
return {
modifyRequestHeader: function (xhr) {
if (value.headers) {
for (const [key, val] of Object.entries(value.headers)) {
xhr.setRequestHeader(key, val);
}
}
return xhr;
}
};
}, true);
// Player event handlers
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PLAYING, () => { sendPlaybackUpdate(1); playerCtrlStateUpdate(PlayerControlEvent.Play); });
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PAUSED, () => { sendPlaybackUpdate(2); playerCtrlStateUpdate(PlayerControlEvent.Pause); });
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ENDED, () => { sendPlaybackUpdate(0) });
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_TIME_UPDATED, () => {
playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate);
if (Math.abs(dashPlayer.time() - playerPrevTime) >= playbackUpdateInterval) {
sendPlaybackUpdate(dashPlayer.isPaused() ? 2 : 1);
playerPrevTime = dashPlayer.time();
}
});
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_RATE_CHANGED, () => { sendPlaybackUpdate(dashPlayer.isPaused() ? 2 : 1) });
// Buffering UI update when paused
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PROGRESS, () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); });
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_VOLUME_CHANGED, () => {
const updateVolume = dashPlayer.isMuted() ? 0 : dashPlayer.getVolume();
playerCtrlStateUpdate(PlayerControlEvent.VolumeChange);
window.electronAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume });
});
dashPlayer.on(dashjs.MediaPlayer.events.ERROR, (data) => { window.electronAPI.sendPlaybackError({
message: `DashJS ERROR: ${JSON.stringify(data)}`
})});
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ERROR, (data) => { window.electronAPI.sendPlaybackError({
message: `DashJS PLAYBACK_ERROR: ${JSON.stringify(data)}`
})});
dashPlayer.on(dashjs.MediaPlayer.events.STREAM_INITIALIZED, () => { onPlayerLoad(value, currentPlaybackRate, currentVolume); });
dashPlayer.on(dashjs.MediaPlayer.events.CUE_ENTER, (e: any) => {
const subtitle = document.createElement("p")
subtitle.setAttribute("id", "subtitle-" + e.cueID)
subtitle.textContent = e.text;
videoCaptions.appendChild(subtitle);
});
dashPlayer.on(dashjs.MediaPlayer.events.CUE_EXIT, (e: any) => {
document.getElementById("subtitle-" + e.cueID)?.remove();
});
dashPlayer.updateSettings({
// debug: {
// logLevel: dashjs.LogLevel.LOG_LEVEL_INFO
// },
streaming: {
text: {
dispatchForManualRendering: true
}
}
});
if (value.content) {
dashPlayer.initialize(videoElement, `data:${value.container};base64,` + window.btoa(value.content), true, value.time);
// dashPlayer.initialize(videoElement, "https://dash.akamaized.net/akamai/test/caption_test/ElephantsDream/elephants_dream_480p_heaac5_1_https.mpd", true);
} else {
dashPlayer.initialize(videoElement, value.url, true, value.time);
}
} else if ((value.container === 'application/vnd.apple.mpegurl' || value.container === 'application/x-mpegURL') && !videoElement.canPlayType(value.container)) {
console.log("Loading hls player");
const config = {
xhrSetup: function (xhr: XMLHttpRequest) {
if (value.headers) {
for (const [key, val] of Object.entries(value.headers)) {
xhr.setRequestHeader(key, val);
}
}
},
};
const hlsPlayer = new Hls(config);
hlsPlayer.on(Hls.Events.ERROR, (eventName, data) => {
window.electronAPI.sendPlaybackError({
message: `HLS player error: ${JSON.stringify(data)}`
});
});
hlsPlayer.on(Hls.Events.LEVEL_LOADED, (eventName, level: LevelLoadedData) => {
isLive = level.details.live;
isLivePosition = isLive ? true : false;
});
player = new Player(PlayerType.Hls, videoElement, hlsPlayer);
// value.url = "https://devstreaming-cdn.apple.com/videos/streaming/examples/adv_dv_atmos/main.m3u8?ref=developerinsider.co";
hlsPlayer.loadSource(value.url);
hlsPlayer.attachMedia(videoElement);
// hlsPlayer.subtitleDisplay = true;
} else {
console.log("Loading html player");
player = new Player(PlayerType.Html, videoElement);
videoElement.src = value.url;
videoElement.load();
}
// Player event handlers
if (player.playerType === PlayerType.Hls || player.playerType === PlayerType.Html) {
videoElement.onplay = () => { sendPlaybackUpdate(1); playerCtrlStateUpdate(PlayerControlEvent.Play); };
videoElement.onpause = () => { sendPlaybackUpdate(2); playerCtrlStateUpdate(PlayerControlEvent.Pause); };
videoElement.onended = () => { sendPlaybackUpdate(0) };
videoElement.ontimeupdate = () => {
playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate);
if (Math.abs(videoElement.currentTime - playerPrevTime) >= playbackUpdateInterval) {
sendPlaybackUpdate(videoElement.paused ? 2 : 1);
playerPrevTime = videoElement.currentTime;
}
};
// Buffering UI update when paused
videoElement.onprogress = () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); };
videoElement.onratechange = () => { sendPlaybackUpdate(videoElement.paused ? 2 : 1) };
videoElement.onvolumechange = () => {
const updateVolume = videoElement.muted ? 0 : videoElement.volume;
playerCtrlStateUpdate(PlayerControlEvent.VolumeChange);
window.electronAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume });
};
videoElement.onerror = (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) => {
console.error("Player error", {source, lineno, colno, error});
};
videoElement.onloadedmetadata = () => { onPlayerLoad(value, currentPlaybackRate, currentVolume); };
}
}
// Sender generated event handlers
window.electronAPI.onPause(() => { player.pause(); });
window.electronAPI.onResume(() => { player.play(); });
window.electronAPI.onSeek((_event, value: SeekMessage) => { player.setCurrentTime(value.time); });
window.electronAPI.onSetVolume((_event, value: SetVolumeMessage) => { volumeChangeHandler(value.volume); });
window.electronAPI.onSetSpeed((_event, value: SetSpeedMessage) => { player.setPlaybackRate(value.speed); playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate); });
});
let scrubbing = false;
let volumeChanging = false;
enum PlayerControlEvent {
Load,
Pause,
Play,
VolumeChange,
TimeUpdate,
UiFadeOut,
UiFadeIn,
SetCaptions,
ToggleSpeedMenu,
SetPlaybackRate,
ToggleFullscreen,
ExitFullscreen,
}
// UI update handlers
function playerCtrlStateUpdate(event: PlayerControlEvent) {
export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent) {
switch (event) {
case PlayerControlEvent.Load: {
playerCtrlProgressBarBuffer.setAttribute("style", "width: 0px");
playerCtrlProgressBarProgress.setAttribute("style", "width: 0px");
playerCtrlProgressBarHandle.setAttribute("style", `left: ${playerCtrlProgressBar.offsetLeft}px`);
const volume = Math.round(player.getVolume() * playerCtrlVolumeBar.offsetWidth);
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume + 8}px`);
if (isLive) {
playerCtrlLiveBadge.setAttribute("style", "display: block");
playerCtrlPosition.setAttribute("style", "display: none");
playerCtrlDuration.setAttribute("style", "display: none");
}
else {
playerCtrlLiveBadge.setAttribute("style", "display: none");
playerCtrlPosition.setAttribute("style", "display: block");
playerCtrlDuration.setAttribute("style", "display: block");
playerCtrlPosition.textContent = formatDuration(player.getCurrentTime());
playerCtrlDuration.innerHTML = `/&nbsp&nbsp${formatDuration(player.getDuration())}`;
}
if (player.isCaptionsSupported()) {
playerCtrlCaptions.setAttribute("style", "display: block");
videoCaptions.setAttribute("style", "display: block");
}
else {
playerCtrlCaptions.setAttribute("style", "display: none");
videoCaptions.setAttribute("style", "display: none");
player.enableCaptions(false);
}
playerCtrlStateUpdate(PlayerControlEvent.SetCaptions);
break;
}
case PlayerControlEvent.Pause:
playerCtrlAction.setAttribute("class", "play");
stopUiHideTimer();
break;
case PlayerControlEvent.Play:
playerCtrlAction.setAttribute("class", "pause");
startUiHideTimer();
break;
case PlayerControlEvent.VolumeChange: {
// console.log(`VolumeChange: isMute ${player.isMuted()}, volume: ${player.getVolume()}`);
const volume = Math.round(player.getVolume() * playerCtrlVolumeBar.offsetWidth);
if (player.isMuted()) {
playerCtrlVolume.setAttribute("class", "mute");
playerCtrlVolumeBarProgress.setAttribute("style", `width: 0px`);
playerCtrlVolumeBarHandle.setAttribute("style", `left: 0px`);
}
else if (player.getVolume() >= 0.5) {
playerCtrlVolume.setAttribute("class", "volume_high");
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume}px`);
} else {
playerCtrlVolume.setAttribute("class", "volume_low");
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume}px`);
}
break;
}
case PlayerControlEvent.TimeUpdate: {
// console.log(`TimeUpdate: Position: ${player.getCurrentTime()}, Duration: ${player.getDuration()}`);
if (isLive) {
if (isLivePosition && player.getDuration() - player.getCurrentTime() > livePositionWindow) {
isLivePosition = false;
playerCtrlLiveBadge.setAttribute("style", `background-color: #595959`);
}
else if (!isLivePosition && player.getDuration() - player.getCurrentTime() <= livePositionWindow) {
isLivePosition = true;
playerCtrlLiveBadge.setAttribute("style", `background-color: red`);
}
}
if (isLivePosition) {
playerCtrlProgressBarProgress.setAttribute("style", `width: ${playerCtrlProgressBar.offsetWidth}px`);
playerCtrlProgressBarHandle.setAttribute("style", `left: ${playerCtrlProgressBar.offsetWidth + playerCtrlProgressBar.offsetLeft}px`);
}
else {
const buffer = Math.round((player.getBufferLength() / player.getDuration()) * playerCtrlProgressBar.offsetWidth);
const progress = Math.round((player.getCurrentTime() / player.getDuration()) * playerCtrlProgressBar.offsetWidth);
const handle = progress + playerCtrlProgressBar.offsetLeft;
playerCtrlProgressBarBuffer.setAttribute("style", `width: ${buffer}px`);
playerCtrlProgressBarProgress.setAttribute("style", `width: ${progress}px`);
playerCtrlProgressBarHandle.setAttribute("style", `left: ${handle}px`);
playerCtrlPosition.textContent = formatDuration(player.getCurrentTime());
}
break;
}
case PlayerControlEvent.UiFadeOut:
document.body.style.cursor = "none";
playerControls.setAttribute("style", "opacity: 0");
if (player.isCaptionsEnabled()) {
videoCaptions.setAttribute("style", "display: block; bottom: 75px;");
} else {
videoCaptions.setAttribute("style", "display: none; bottom: 75px;");
}
break;
case PlayerControlEvent.UiFadeIn:
document.body.style.cursor = "default";
playerControls.setAttribute("style", "opacity: 1");
if (player.isCaptionsEnabled()) {
videoCaptions.setAttribute("style", "display: block; bottom: 160px;");
} else {
videoCaptions.setAttribute("style", "display: none; bottom: 160px;");
}
break;
case PlayerControlEvent.SetCaptions:
if (player.isCaptionsEnabled()) {
playerCtrlCaptions.setAttribute("class", "captions_on");
videoCaptions.setAttribute("style", "display: block");
} else {
playerCtrlCaptions.setAttribute("class", "captions_off");
videoCaptions.setAttribute("style", "display: none");
}
break;
case PlayerControlEvent.ToggleSpeedMenu: {
if (playerCtrlSpeedMenuShown) {
playerCtrlSpeedMenu.setAttribute("style", "display: none");
} else {
playerCtrlSpeedMenu.setAttribute("style", "display: block");
}
playerCtrlSpeedMenuShown = !playerCtrlSpeedMenuShown;
break;
}
case PlayerControlEvent.SetPlaybackRate: {
const rate = player.getPlaybackRate().toFixed(2);
const entryElement = document.getElementById(`speedMenuEntry_${rate}_enabled`);
playbackRates.forEach(r => {
const entry = document.getElementById(`speedMenuEntry_${r}_enabled`);
entry.setAttribute("style", "opacity: 0");
});
// Ignore updating GUI for custom rates
if (entryElement !== null) {
entryElement.setAttribute("style", "opacity: 1");
}
break;
}
case PlayerControlEvent.ToggleFullscreen: {
window.electronAPI.toggleFullScreen();
@ -502,199 +30,8 @@ function playerCtrlStateUpdate(event: PlayerControlEvent) {
}
}
function scrubbingMouseUIHandler(e: MouseEvent) {
const progressBarOffset = e.offsetX - 8;
const progressBarWidth = PlayerCtrlProgressBarInteractiveArea.offsetWidth - 16;
let time = isLive ? Math.round((1 - (progressBarOffset / progressBarWidth)) * player.getDuration()) : Math.round((progressBarOffset / progressBarWidth) * player.getDuration());
time = Math.min(player.getDuration(), Math.max(0.0, time));
if (scrubbing && isLive && e.buttons === 1) {
isLivePosition = false;
playerCtrlLiveBadge.setAttribute("style", `background-color: #595959`);
}
const livePrefix = isLive && Math.floor(time) !== 0 ? "-" : "";
playerCtrlProgressBarPosition.textContent = isLive ? `${livePrefix}${formatDuration(time)}` : formatDuration(time);
let offset = e.offsetX - (playerCtrlProgressBarPosition.offsetWidth / 2);
offset = Math.min(PlayerCtrlProgressBarInteractiveArea.offsetWidth - (playerCtrlProgressBarPosition.offsetWidth / 1), Math.max(8, offset));
playerCtrlProgressBarPosition.setAttribute("style", `display: block; left: ${offset}px`);
}
// Receiver generated event handlers
playerCtrlAction.onclick = () => {
if (player.isPaused()) {
player.play();
} else {
player.pause();
}
};
playerCtrlVolume.onclick = () => { player.setMute(!player.isMuted()); };
PlayerCtrlProgressBarInteractiveArea.onmousedown = (e: MouseEvent) => { scrubbing = true; scrubbingMouseHandler(e) };
PlayerCtrlProgressBarInteractiveArea.onmouseup = () => { scrubbing = false; };
PlayerCtrlProgressBarInteractiveArea.onmouseenter = (e: MouseEvent) => {
if (e.buttons === 0) {
volumeChanging = false;
}
scrubbingMouseUIHandler(e);
};
PlayerCtrlProgressBarInteractiveArea.onmouseleave = () => { playerCtrlProgressBarPosition.setAttribute("style", "display: none"); };
PlayerCtrlProgressBarInteractiveArea.onmousemove = (e: MouseEvent) => { scrubbingMouseHandler(e) };
function scrubbingMouseHandler(e: MouseEvent) {
const progressBarOffset = e.offsetX - 8;
const progressBarWidth = PlayerCtrlProgressBarInteractiveArea.offsetWidth - 16;
let time = Math.round((progressBarOffset / progressBarWidth) * player.getDuration());
time = Math.min(player.getDuration(), Math.max(0.0, time));
if (scrubbing && e.buttons === 1) {
player.setCurrentTime(time);
}
scrubbingMouseUIHandler(e);
}
playerCtrlVolumeBarInteractiveArea.onmousedown = (e: MouseEvent) => { volumeChanging = true; volumeChangeMouseHandler(e) };
playerCtrlVolumeBarInteractiveArea.onmouseup = () => { volumeChanging = false; };
playerCtrlVolumeBarInteractiveArea.onmouseenter = (e: MouseEvent) => {
if (e.buttons === 0) {
scrubbing = false;
}
};
playerCtrlVolumeBarInteractiveArea.onmousemove = (e: MouseEvent) => { volumeChangeMouseHandler(e) };
playerCtrlVolumeBarInteractiveArea.onwheel = (e: WheelEvent) => {
const delta = -e.deltaY;
if (delta > 0 ) {
volumeChangeHandler(Math.min(player.getVolume() + volumeIncrement, 1));
} else if (delta < 0) {
volumeChangeHandler(Math.max(player.getVolume() - volumeIncrement, 0));
}
};
function volumeChangeMouseHandler(e: MouseEvent) {
if (volumeChanging && e.buttons === 1) {
const volumeBarOffsetX = e.offsetX - 8;
const volumeBarWidth = playerCtrlVolumeBarInteractiveArea.offsetWidth - 16;
const volume = volumeBarOffsetX / volumeBarWidth;
volumeChangeHandler(volume);
}
}
function volumeChangeHandler(volume: number) {
if (!player.isMuted() && volume <= 0) {
player.setMute(true);
}
else if (player.isMuted() && volume > 0) {
player.setMute(false);
}
player.setVolume(volume);
}
playerCtrlLiveBadge.onclick = () => { setLivePosition(); };
function setLivePosition() {
if (!isLivePosition) {
isLivePosition = true;
player.setCurrentTime(player.getDuration() - livePositionDelta);
playerCtrlLiveBadge.setAttribute("style", `background-color: red`);
if (player.isPaused()) {
player.play();
}
}
}
playerCtrlCaptions.onclick = () => { player.enableCaptions(!player.isCaptionsEnabled()); playerCtrlStateUpdate(PlayerControlEvent.SetCaptions); };
playerCtrlSpeed.onclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu); };
playerCtrlFullscreen.onclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); };
playbackRates.forEach(r => {
const entry = document.getElementById(`speedMenuEntry_${r}`);
entry.onclick = () => {
player.setPlaybackRate(parseFloat(r));
playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate);
playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu);
};
});
videoElement.onclick = () => {
if (!playerCtrlSpeedMenuShown) {
if (player.isPaused()) {
player.play();
} else {
player.pause();
}
}
};
videoElement.ondblclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); };
// Component hiding
let uiHideTimer = null;
let uiVisible = true;
function startUiHideTimer() {
if (uiHideTimer === null) {
uiHideTimer = window.setTimeout(() => {
uiHideTimer = null;
uiVisible = false;
playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut);
}, 3000);
}
}
function stopUiHideTimer() {
if (uiHideTimer) {
window.clearTimeout(uiHideTimer);
uiHideTimer = null;
}
if (!uiVisible) {
uiVisible = true;
playerCtrlStateUpdate(PlayerControlEvent.UiFadeIn);
}
}
document.onmouseout = () => {
if (uiHideTimer) {
window.clearTimeout(uiHideTimer);
uiHideTimer = null;
}
uiVisible = false;
playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut);
}
document.onmousemove = () => {
stopUiHideTimer();
if (player && !player.isPaused()) {
startUiHideTimer();
}
};
window.onresize = () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); };
// Listener for hiding speed menu when clicking outside element
document.addEventListener('click', (event: MouseEvent) => {
const node = event.target as Node;
if (playerCtrlSpeedMenuShown && !playerCtrlSpeed.contains(node) && !playerCtrlSpeedMenu.contains(node)){
playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu);
}
});
// Add the keydown event listener to the document
const skipInterval = 10;
const volumeIncrement = 0.1;
document.addEventListener('keydown', (event) => {
// console.log("KeyDown", event);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function targetKeyDownEventListener(event: any) {
switch (event.code) {
case 'KeyF':
case 'F11':
@ -705,53 +42,7 @@ document.addEventListener('keydown', (event) => {
playerCtrlStateUpdate(PlayerControlEvent.ExitFullscreen);
event.preventDefault();
break;
case 'ArrowLeft':
// Skip back
player.setCurrentTime(Math.max(player.getCurrentTime() - skipInterval, 0));
event.preventDefault();
break;
case 'ArrowRight':
// Skip forward
if (!isLivePosition) {
player.setCurrentTime(Math.min(player.getCurrentTime() + skipInterval, player.getDuration()));
}
event.preventDefault();
break;
case "Home":
player.setCurrentTime(0);
event.preventDefault();
break;
case "End":
if (isLive) {
setLivePosition();
}
else {
player.setCurrentTime(player.getDuration());
}
event.preventDefault();
break;
case 'KeyK':
case 'Space':
case 'Enter':
// Pause/Continue
if (player.isPaused()) {
player.play();
} else {
player.pause();
}
event.preventDefault();
break;
case 'KeyM':
// Mute toggle
player.setMute(!player.isMuted());
break;
case 'ArrowUp':
// Volume up
volumeChangeHandler(Math.min(player.getVolume() + volumeIncrement, 1));
break;
case 'ArrowDown':
// Volume down
volumeChangeHandler(Math.max(player.getVolume() - volumeIncrement, 0));
default:
break;
}
});
};

View file

@ -1,10 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="../../assets/fonts/inter.css" />
<link rel="stylesheet" href="./style.css" />
<title>FCast Receiver</title>
<meta charset="UTF-8">
<link rel="stylesheet" href="../assets/fonts/inter.css" />
<link rel="stylesheet" href="./common.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<video id="videoPlayer" autoplay preload="auto"></video>

View file

@ -1,337 +1,15 @@
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%;
}
#videoPlayer {
object-fit: contain;
width: 100%;
height: 100%;
}
*:focus {
outline: none;
box-shadow: none;
}
.container {
position: absolute;
bottom: 0px;
/* height: 100%; */
height: 120px;
width: 100%;
/* background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 100%); */
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.0) 35%);
background-size: 100% 300px;
background-repeat: no-repeat;
background-position: bottom;
opacity: 1;
transition: opacity 0.1s ease-in-out;
}
.volumeContainer {
position: relative;
height: 24px;
width: 92px;
flex-shrink: 0;
user-select: none;
}
.volumeBar {
position: absolute;
/* left: 12px; */
left: 8px;
top: 10px;
height: 4px;
/* width: 72px; */
width: 76px;
background-color: #999999;
border-radius: 3px;
pointer-events: none;
}
.volumeBarInteractiveArea {
position: absolute;
left: 0px;
/* left: 8px; */
top: 0px;
height: 24px;
width: 92px;
/* width: 84px; */
cursor: pointer;
}
.volumeBarHandle {
position: absolute;
left: 84px;
top: 4px;
width: 16px;
height: 16px;
/* background-color: #ffffff; */
background-color: #c9c9c9;
box-shadow: 0px 32px 64px 0px rgba(0, 0, 0, 0.56), 0px 2px 21px 0px rgba(0, 0, 0, 0.55);
border-radius: 50%;
pointer-events: none;
z-index: 10;
}
.volumeBarProgress {
position: absolute;
/* left: 12px; */
left: 8px;
top: 10px;
height: 4px;
width: 76px;
/* background-color: #ffffff; */
background-color: #c9c9c9;
border-radius: 3px;
pointer-events: none;
}
.progressBarContainer {
position: absolute;
bottom: 60px;
left: 16px;
right: 16px;
height: 4px;
padding-top: 10px;
padding-bottom: 10px;
border-radius: 3px;
cursor: pointer;
user-select: none;
}
.progressBarInteractiveArea {
position: absolute;
/* bottom: 60px; */
/* left: 24px; */
/* right: 24px; */
height: 4px;
width: 100%;
left: 0px;
bottom: 0px;
padding-top: 10px;
padding-bottom: 10px;
border-radius: 3px;
cursor: pointer;
z-index: 999;
}
.progressBarChapterContainer {
position: absolute;
bottom: 73px;
left: 24px;
right: 24px;
height: 4px;
border-radius: 3px;
cursor: pointer;
}
.progressBar {
/* position: absolute; */
position: relative;
/* bottom: 70px; */
/* left: 24px; */
/* right: 24px; */
left: 8px;
width: calc(100% - 16px);
height: 4px;
background-color: #99999945;
border-radius: 3px;
pointer-events: none;
}
.progressBarBuffer {
/* position: absolute; */
position: relative;
/* bottom: 70px; */
/* left: 24px; */
left: 8px;
bottom: 4px;
height: 4px;
background-color: #D9D9D945;
border-radius: 3px;
pointer-events: none;
}
.progressBarProgress {
/* position: absolute; */
position: relative;
/* bottom: 70px; */
/* left: 24px; */
left: 8px;
bottom: 8px;
height: 4px;
width: 0px;
background-color: #019BE7;
border-radius: 3px;
pointer-events: none;
}
.progressBarPosition {
display: none;
position: absolute;
bottom: 25px;
padding: 2px 5px;
font-family: InterVariable;
font-size: 16px;
font-style: normal;
font-weight: 400;
border-radius: 3px;
background-color: rgba(0, 0, 0, 0.5);
}
.progressBarHandle {
position: absolute;
/* bottom: 70px; */
bottom: 10px;
width: 20px;
height: 20px;
margin-left: -8px;
margin-bottom: -8px;
background-color: #019BE7;
border-radius: 50%;
pointer-events: none;
z-index: 10;
}
.positionContainer {
display: flex;
flex-direction: row;
flex-grow: 1;
align-items: center;
font-family: InterVariable;
font-size: 16px;
font-style: normal;
font-weight: 400;
user-select: text;
}
.position {
margin-right: 10px;
vertical-align: bottom;
color: #c9c9c9;
}
.duration {
opacity: 0.6;
color: #c9c9c9;
}
.liveBadge {
background-color: red;
/* margin-top: -2px; */
/* padding: 5px 5px; */
padding: 2px 5px;
border-radius: 4px;
/* margin-left: 10px; */
margin-right: 10px;
cursor: pointer;
}
.play {
width: 24px;
height: 24px;
cursor: pointer;
flex-shrink: 0;
background-image: url("../../assets/icons/player/icon24_play.svg");
transition: background-image 0.1s ease-in-out;
}
.play:hover {
background-image: url("../../assets/icons/player/icon24_play_active.svg");
}
.pause {
width: 24px;
height: 24px;
cursor: pointer;
flex-shrink: 0;
background-image: url("../../assets/icons/player/icon24_pause.svg");
transition: background-image 0.1s ease-in-out;
}
.pause:hover {
background-image: url("../../assets/icons/player/icon24_pause_active.svg");
}
.volume_high {
width: 24px;
height: 24px;
cursor: pointer;
flex-shrink: 0;
background-image: url("../../assets/icons/player/icon24_volume_more_50pct.svg");
transition: background-image 0.1s ease-in-out;
}
.volume_high:hover {
background-image: url("../../assets/icons/player/icon24_volume_more_50pct_active.svg");
}
.volume_low {
width: 24px;
height: 24px;
cursor: pointer;
flex-shrink: 0;
background-image: url("../../assets/icons/player/icon24_volume_less_50pct.svg");
transition: background-image 0.1s ease-in-out;
}
.volume_low:hover {
background-image: url("../../assets/icons/player/icon24_volume_less_50pct_active.svg");
}
.mute {
width: 24px;
height: 24px;
cursor: pointer;
flex-shrink: 0;
background-image: url("../../assets/icons/player/icon24_mute.svg");
transition: background-image 0.1s ease-in-out;
}
.mute:hover {
background-image: url("../../assets/icons/player/icon24_mute_active.svg");
}
.fullscreen_on {
width: 24px;
height: 24px;
cursor: pointer;
background-image: url("../../assets/icons/player/icon24_fullscreen_on.svg");
background-image: url("../assets/icons/player/icon24_fullscreen_on.svg");
transition: background-image 0.1s ease-in-out;
}
.fullscreen_on:hover {
background-image: url("../../assets/icons/player/icon24_fullscreen_on_active.svg");
background-image: url("../assets/icons/player/icon24_fullscreen_on_active.svg");
}
.fullscreen_off {
@ -339,157 +17,10 @@ body {
height: 24px;
cursor: pointer;
background-image: url("../../assets/icons/player/icon24_fullscreen_off.svg");
background-image: url("../assets/icons/player/icon24_fullscreen_off.svg");
transition: background-image 0.1s ease-in-out;
}
.fullscreen_off:hover {
background-image: url("../../assets/icons/player/icon24_fullscreen_off_active.svg");
}
.speed {
width: 24px;
height: 24px;
cursor: pointer;
background-image: url("../../assets/icons/player/icon24_speed.svg");
transition: background-image 0.1s ease-in-out;
}
.speed:hover {
background-image: url("../../assets/icons/player/icon24_speed_active.svg");
}
.captions_off {
width: 24px;
height: 24px;
cursor: pointer;
background-image: url("../../assets/icons/player/icon24_cc_off.svg");
transition: background-image 0.1s ease-in-out;
}
.captions_off:hover {
background-image: url("../../assets/icons/player/icon24_cc_off_active.svg");
}
.captions_on {
width: 24px;
height: 24px;
cursor: pointer;
background-image: url("../../assets/icons/player/icon24_cc_on.svg");
transition: background-image 0.1s ease-in-out;
}
.captions_on:hover {
background-image: url("../../assets/icons/player/icon24_cc_on_active.svg");
}
.leftButtonContainer {
position: absolute;
bottom: 24px;
left: 24px;
height: 24px;
/* width: calc(50% - 24px); */
right: 160px;
display: flex;
flex-direction: row;
align-items: center;
gap: 24px;
overflow: hidden;
user-select: none;
}
.buttonContainer {
position: absolute;
bottom: 24px;
right: 24px;
height: 24px;
/* width: calc(50% - 24px); */
align-items: center;
overflow: hidden;
display: flex;
flex-direction: row-reverse;
gap: 24px;
}
.captionsContainer {
/* display: none; */
position: relative;
/* top: -200px; */
bottom: 160px;
margin: auto;
text-align: center;
font-family: InterVariable;
font-size: 28px;
font-style: normal;
font-weight: 400;
background-color: rgba(0, 0, 0, 0.5);
padding: 0px 5px;
width: fit-content;
transition: bottom 0.2s ease-in-out;
}
.speedMenu {
position: absolute;
bottom: 80px;
right: 60px;
height: calc(55vh);
max-height: 368px;
background-color: #141414;
padding: 12px;
border-radius: 10px;
border: 1px solid #2E2E2E;
scrollbar-width: thin;
overflow: auto;
font-family: InterVariable;
font-size: 16px;
font-style: normal;
font-weight: 400;
box-shadow: 0px 1.852px 3.148px 0px rgba(0, 0, 0, 0.06), 0px 8.148px 6.519px 0px rgba(0, 0, 0, 0.10), 0px 20px 13px 0px rgba(0, 0, 0, 0.13), 0px 38.519px 25.481px 0px rgba(0, 0, 0, 0.15), 0px 64.815px 46.852px 0px rgba(0, 0, 0, 0.19), 0px 100px 80px 0px rgba(0, 0, 0, 0.25);
}
.speedMenuTitle {
font-weight: 700;
line-height: 24px;
margin: 10px;
}
.speedMenuEntry {
display: flex;
padding: 10px 15px;
}
.speedMenuEntry:hover {
cursor: pointer;
background-color: rgba(255, 255, 255, 0.1);
}
.speedMenuSeparator {
height: 1px;
background: #2E2E2E;
margin-top: 3px;
margin-bottom: 3px;
}
.speedMenuEntryEnabled {
width: 20px;
height: 20px;
margin-right: 10px;
background-image: url("../../assets/icons/player/icon24_check_thin.svg");
background-size: cover;
opacity: 0;
background-image: url("../assets/icons/player/icon24_fullscreen_off_active.svg");
}

View file

@ -9,7 +9,13 @@
"experimentalDecorators": true,
"removeComments": false,
"noImplicitAny": false,
"outDir": "dist"
"outDir": "dist",
"baseUrl": ".",
"paths": {
"src/*": ["./src/*"],
"modules/*": ["./node_modules/*"],
"common/*": ["../common/web/*"],
}
},
"exclude": [ "node_modules", "test" ]
}

View file

@ -1,8 +1,16 @@
const webpack = require('webpack');
const path = require('path');
const CopyWebpackPlugin = require("copy-webpack-plugin");
// const buildMode = 'production';
const buildMode = 'development';
const TARGET = 'electron';
// const TARGET = 'webOS';
// const TARGET = 'tizenOS';
module.exports = [
{
mode: 'development',
mode: buildMode,
entry: './src/App.ts',
target: 'electron-main',
module: {
@ -15,15 +23,43 @@ module.exports = [
],
},
resolve: {
alias: {
'src': path.resolve(__dirname, 'src'),
'modules': path.resolve(__dirname, 'node_modules'),
'common': path.resolve(__dirname, '../common/web'),
},
extensions: ['.tsx', '.ts', '.js'],
},
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
plugins: [
new CopyWebpackPlugin({
patterns: [
// Common assets
{
from: '../common/assets/**',
to: './[path][name][ext]',
context: path.resolve(__dirname, '..', 'common'),
globOptions: { ignore: ['**/*.txt'] }
},
// Target assets
{
from: '**',
to: './assets/[path][name][ext]',
context: path.resolve(__dirname, 'assets'),
globOptions: { ignore: [] }
}
],
}),
new webpack.DefinePlugin({
TARGET: JSON.stringify(TARGET)
})
]
},
{
mode: 'development',
mode: buildMode,
entry: {
preload: './src/main/Preload.ts',
renderer: './src/main/Renderer.ts',
@ -39,15 +75,38 @@ module.exports = [
],
},
resolve: {
alias: {
'src': path.resolve(__dirname, 'src'),
'modules': path.resolve(__dirname, 'node_modules'),
'common': path.resolve(__dirname, '../common/web'),
},
extensions: ['.tsx', '.ts', '.js'],
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist/main'),
},
plugins: [
new CopyWebpackPlugin({
patterns: [
{
from: '../common/web/main/common.css',
to: '[name][ext]',
},
{
from: './src/main/*',
to: '[name][ext]',
globOptions: { ignore: ['**/*.ts'] }
}
],
}),
new webpack.DefinePlugin({
TARGET: JSON.stringify(TARGET)
})
]
},
{
mode: 'development',
mode: buildMode,
entry: {
preload: './src/player/Preload.ts',
renderer: './src/player/Renderer.ts',
@ -63,11 +122,34 @@ module.exports = [
],
},
resolve: {
alias: {
'src': path.resolve(__dirname, 'src'),
'modules': path.resolve(__dirname, 'node_modules'),
'common': path.resolve(__dirname, '../common/web'),
},
extensions: ['.tsx', '.ts', '.js'],
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist/player'),
},
plugins: [
new CopyWebpackPlugin({
patterns: [
{
from: '../common/web/player/common.css',
to: '[name][ext]',
},
{
from: './src/player/*',
to: '[name][ext]',
globOptions: { ignore: ['**/*.ts'] }
}
],
}),
new webpack.DefinePlugin({
TARGET: JSON.stringify(TARGET)
})
]
}
];

View file

@ -0,0 +1,133 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Ignore VSCode user project settings
.vscode

View file

@ -0,0 +1,3 @@
{
"api_level": "0"
}

View file

@ -0,0 +1,95 @@
# What is FCast?
FCast is a protocol designed for wireless streaming of audio and video content between devices. Unlike alternative protocols like Chromecast and AirPlay, FCast is an open source protocol that allows for custom receiver implementations, enabling third-party developers to create their own receiver devices or integrate the FCast protocol into their own apps.
# Why do I need a receiver?
The FCast receiver is a working receiver implementation compatible with Linux, Windows and MacOS that supports various stream types such as DASH, HLS and mp4.
![FCast receiver running on Linux](images/player-window.png)
# Protocol specification
The protocol specification can be found here https://gitlab.futo.org/videostreaming/fcast/-/wikis/home
# Receiver application
1. Download the latest build for your platform from https://fcast.org/#downloads or build it yourself by following the build instructions.
2. Unzip the archive at your desired location.
3. Run the FCast receiver.
- **MacOS:** Run the `FCast Receiver` application
- **Linux:** Run the `fcast-receiver` application
- **Windows:** Run the `fcast-receiver.exe` application
4. The application will open the main window where you can setup a connection to your sender device.
![Main window](images/main-window.png)
The application will continue to run in the system tray when you close the player or main window. You can exit the application or access other menu options from the tray icon.
There are also command line flags to customize application behavior, some of which include:
* `--no-main-window`: Hide the main window on start
* `--fullscreen`: Start the main window in fullscreen
Use the `--help` flag to see full list of available flags.
# Connecting to the FCast receiver with the video streaming application
## Automatic discovery
1. Open the video streaming application.
2. Open the FCast receiver or restart it.
3. The receiver should now be visible in the casting dialog under "Discovered Devices".
4. If this failed, try manually connecting it. Automatic discovery does not work on all network types.
5. Click start to connect to the device.
6. Start watching content.
## Manual
1. Open the FCast receiver.
2. Find the IP of the device running the receiver.
3. Open the video streaming application.
4. Open the casting dialog.
5. Click add to manually add a device.
6. Select the FCast protocol, enter a descriptive name, the IP you found and port 46899.
7. Click start to connect to the device.
8. Start watching content.
![Manual add dialog in video streaming app](images/Untitled3.png)
# How to build
## Preparing for build
A docker file is provided to setup your build environment. From the root of the repository:
* Build: `docker build -t fcast/receiver-electron-dev:latest receivers/electron/`
* Run: `docker run --rm -it -w /app/receivers/electron --entrypoint='bash' -v .:/app fcast/receiver-electron-dev:latest`
You can then run the following commands to finish setup.
```
npm install
```
## Development
Run the following commands in the `/app/receivers/electron` directory.
* Build: `npm run build`
* Run: `npm run start`
## Packaging
Below are the following platforms currently used for packaging:
* `npm run make -- --platform="darwin" --arch="arm64"`
* `npm run make -- --platform="darwin" --arch="x64"`
* `npm run make -- --platform="win32" --arch="x64"`
* `npm run make -- --platform="linux" --arch="x64"`
* `npm run make -- --platform="linux" --arch="arm64"`
Other platforms and architectures supported by [Electron Forge](https://www.electronforge.io/) might work, but are currently untested.
Packages that will be built from running the above `make` commands:
* Windows: `.msi` and `.zip`
* MacOS: `.dmg` and `.zip`
* Linux: `.deb`, `.rpm` and `.zip`
Package artifacts will be located in `/app/receivers/electron/out/make`

View file

@ -0,0 +1,11 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
export default [
{files: ["**/*.{js,mjs,cjs,ts}"]},
{languageOptions: { globals: globals.node }},
pluginJs.configs.recommended,
...tseslint.configs.recommended,
];

View file

@ -0,0 +1,6 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['<rootDir>/test/**/*.test.ts'],
modulePathIgnorePatterns: ["<rootDir>/packaging/fcast/fcast-receiver-linux-x64/resources/app/package.json"],
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
{
"name": "com.futo.fcast.receiver.service",
"version": "1.0.0",
"description": "FCast network service",
"author": "FUTO",
"license": "MIT",
"main": "main.js",
"scripts": {
"build": "rm -rf dist/ && webpack --config ./webpack.config.js",
"test": "echo \"Error: no test specified\" && exit 1",
"postinstall": "patch-package"
},
"devDependencies": {
"@eslint/js": "^9.10.0",
"@types/jest": "^29.5.11",
"@types/mdns": "^0.0.38",
"@types/node-forge": "^1.3.10",
"@types/qrcode": "^1.5.5",
"@types/webos-service": "^0.4.6",
"@types/webostvjs": "^1.2.6",
"@types/ws": "^8.5.10",
"copy-webpack-plugin": "^12.0.2",
"eslint": "^9.10.0",
"globals": "^15.9.0",
"jest": "^29.7.0",
"mdns-js": "github:mdns-js/node-mdns-js",
"ts-jest": "^29.1.1",
"ts-loader": "^9.4.2",
"typescript": "^5.5.4",
"typescript-eslint": "^8.4.0",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.1"
},
"dependencies": {
"http": "^0.0.1-security",
"log4js": "^6.9.1",
"url": "^0.11.3",
"uuid": "^9.0.1",
"ws": "^8.14.2"
}
}

View file

@ -0,0 +1,18 @@
diff --git a/node_modules/@types/webos-service/index.d.ts b/node_modules/@types/webos-service/index.d.ts
index a860f4f..ca94645 100644
--- a/node_modules/@types/webos-service/index.d.ts
+++ b/node_modules/@types/webos-service/index.d.ts
@@ -1,5 +1,5 @@
-export as namespace WebosService;
-
+export = Service;
+export as namespace Service;
import { ActivityManager } from "./activity-manager";
import { Message } from "./message";
import { Method } from "./method";
@@ -7,4 +7,4 @@ import { Service } from "./service";
import { Subscription } from "./subscription";
export { ActivityManager, Message, Method, Subscription };
-export default Service;
+

View file

@ -0,0 +1,9 @@
{
"id": "com.futo.fcast.receiver.service",
"description": "FCast network service",
"services": [
{
"name": "com.futo.fcast.receiver.service"
}
]
}

View file

@ -0,0 +1,268 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// No node module for this package, only exists in webOS environment
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const Service = __non_webpack_require__('webos-service');
// const Service = require('webos-service');
import { PlayMessage, PlaybackErrorMessage, PlaybackUpdateMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage, VolumeUpdateMessage } from 'common/Packets';
import { DiscoveryService } from 'common/DiscoveryService';
import { TcpListenerService } from 'common/TcpListenerService';
import { WebSocketListenerService } from 'common/WebSocketListenerService';
import { NetworkService } from 'common/NetworkService';
import { Opcode } from 'common/FCastSession';
import * as os from 'os';
import * as log4js from "log4js";
import { EventEmitter } from 'node:events';
export class Main {
static tcpListenerService: TcpListenerService;
static webSocketListenerService: WebSocketListenerService;
static discoveryService: DiscoveryService;
static logger: log4js.Logger;
static {
try {
log4js.configure({
appenders: {
console: { type: 'console' },
},
categories: {
default: { appenders: ['console'], level: 'info' },
},
});
Main.logger = log4js.getLogger();
// Main.logger.info(`Starting application: ${app.name} | ${app.getAppPath()}`);
// Main.logger.info(`Version: ${app.getVersion()}`);
// Main.logger.info(`Commit: ${Updater.getCommit()}`);
// Main.logger.info(`Release channel: ${Updater.releaseChannel} - ${Updater.getChannelVersion()}`);
Main.logger.info(`OS: ${process.platform} ${process.arch}`);
const serviceId = 'com.futo.fcast.receiver.service';
const service = new Service(serviceId);
// let keepAlive;
// service.activityManager.create("keepAlive", function(activity) {
// keepAlive = activity;
// });
// // When you&#39;re done, complete the activity
// service.activityManager.complete(keepAlive, function(activity) {
// Main.logger.info("completed activity");
// });
service.register("getDeviceInfo", (message: any) => {
Main.logger.info("In getDeviceInfo callback");
message.respond({
returnValue: true,
value: { name: os.hostname(), addresses: NetworkService.getAllIPv4Addresses() }
});
});
Main.discoveryService = new DiscoveryService();
Main.discoveryService.start();
Main.tcpListenerService = new TcpListenerService();
Main.webSocketListenerService = new WebSocketListenerService();
const emitter = new EventEmitter();
let playData: PlayMessage = null;
let playClosureCb = null;
const playCb = (message: any, playMessage: PlayMessage) => {
playData = playMessage;
message.respond({ returnValue: true, value: { playData: playData } });
};
let pauseClosureCb: any = null;
let resumeClosureCb: any = null;
let stopClosureCb: any = null;
const voidCb = (message: any) => { message.respond({ returnValue: true, value: {} }); };
let seekClosureCb = null;
const seekCb = (message: any, seekMessage: SeekMessage) => { message.respond({ returnValue: true, value: seekMessage }); };
let setVolumeClosureCb = null;
const setVolumeCb = (message: any, volumeMessage: SetVolumeMessage) => { message.respond({ returnValue: true, value: volumeMessage }); };
let setSpeedClosureCb = null;
const setSpeedCb = (message: any, speedMessage: SetSpeedMessage) => { message.respond({ returnValue: true, value: speedMessage }); };
// Note: When logging the `message` object, do NOT use JSON.stringify, you can log messages directly. Seems to be a circular reference causing errors...
// const playService = service.register("play", (message) => {
service.register("play", (message: any) => {
if (message.isSubscription) {
playClosureCb = playCb.bind(this, message);
emitter.on('play', playClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true, playData: playData }});
},
(message: any) => {
Main.logger.info('Canceled play service subscriber');
emitter.off('play', playClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
service.register("pause", (message: any) => {
if (message.isSubscription) {
pauseClosureCb = voidCb.bind(this, message);
emitter.on('pause', pauseClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true }});
},
(message: any) => {
Main.logger.info('Canceled pause service subscriber');
emitter.off('pause', pauseClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
service.register("resume", (message: any) => {
if (message.isSubscription) {
resumeClosureCb = voidCb.bind(this, message);
emitter.on('resume', resumeClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true }});
},
(message: any) => {
Main.logger.info('Canceled resume service subscriber');
emitter.off('resume', resumeClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
service.register("stop", (message: any) => {
playData = null;
if (message.isSubscription) {
stopClosureCb = voidCb.bind(this, message);
emitter.on('stop', stopClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true }});
},
(message: any) => {
Main.logger.info('Canceled stop service subscriber');
emitter.off('stop', stopClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
service.register("seek", (message: any) => {
if (message.isSubscription) {
seekClosureCb = seekCb.bind(this, message);
emitter.on('seek', seekClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true }});
},
(message: any) => {
Main.logger.info('Canceled seek service subscriber');
emitter.off('seek', seekClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
service.register("setvolume", (message: any) => {
if (message.isSubscription) {
setVolumeClosureCb = setVolumeCb.bind(this, message);
emitter.on('setvolume', setVolumeClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true }});
},
(message: any) => {
Main.logger.info('Canceled setvolume service subscriber');
emitter.off('setvolume', setVolumeClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
service.register("setspeed", (message: any) => {
if (message.isSubscription) {
setSpeedClosureCb = setSpeedCb.bind(this, message);
emitter.on('setspeed', setSpeedClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true }});
},
(message: any) => {
Main.logger.info('Canceled setspeed service subscriber');
emitter.off('setspeed', setSpeedClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
const listeners = [Main.tcpListenerService, Main.webSocketListenerService];
listeners.forEach(l => {
l.emitter.on("play", async (message) => {
await NetworkService.proxyPlayIfRequired(message);
emitter.emit('play', message);
service.call("luna://com.webos.applicationManager/launch", { playData: message }, (_response) => {
console.log(`Relaunching FCast Receiver with args: ${JSON.stringify(message)}`);
});
// const appId = 'com.futo.fcast.receiver';
// window.webOSDev.launch({
// id: appId,
// params: {
// playData: message,
// },
// onSuccess: function () {
// console.log(`Service: Launching application...`);
// },
// onFailure: function (message: any) {
// console.error(`Service: launch ${JSON.stringify(message)}`);
// },
// });
});
l.emitter.on("pause", () => emitter.emit('pause'));
l.emitter.on("resume", () => emitter.emit('resume'));
l.emitter.on("stop", () => emitter.emit('stop'));
l.emitter.on("seek", (message) => emitter.emit('seek', message));
l.emitter.on("setvolume", (message) => emitter.emit('setvolume', message));
l.emitter.on("setspeed", (message) => emitter.emit('setspeed', message));
l.start();
});
service.register("send_playback_error", (message: any) => {
listeners.forEach(l => {
const value: PlaybackErrorMessage = message.payload.error;
l.send(Opcode.PlaybackError, value);
});
message.respond({ returnValue: true, value: { success: true } });
});
service.register("send_playback_update", (message: any) => {
// Main.logger.info("In send_playback_update callback");
listeners.forEach(l => {
const value: PlaybackUpdateMessage = message.payload.update;
l.send(Opcode.PlaybackUpdate, value);
});
message.respond({ returnValue: true, value: { success: true } });
});
service.register("send_volume_update", (message: any) => {
listeners.forEach(l => {
const value: VolumeUpdateMessage = message.payload.update;
l.send(Opcode.VolumeUpdate, value);
});
message.respond({ returnValue: true, value: { success: true } });
});
}
catch (err) {
Main.logger.error("Error initializing service:", err);
}
}
}
export function getComputerName() {
return os.hostname();
}
export async function errorHandler(err: NodeJS.ErrnoException) {
Main.logger.error("Application error:", err);
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"sourceMap": false,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"removeComments": false,
"noImplicitAny": false,
"outDir": "dist",
"baseUrl": ".",
"paths": {
"src/*": ["./src/*"],
"modules/*": ["./node_modules/*"],
"common/*": ["../../common/web/*"],
}
},
"exclude": [ "node_modules", "test" ]
}

View file

@ -0,0 +1,55 @@
const webpack = require('webpack');
const path = require('path');
const CopyWebpackPlugin = require("copy-webpack-plugin");
// Build issues:
// * 'development' mode breaks running the service on WebOS hardware... Must use 'production'.
// * Must use '--no-minify' when packaging since packaging would break otherwise...
const buildMode = 'production';
// const buildMode = 'development';
// const TARGET = 'electron';
const TARGET = 'webOS';
// const TARGET = 'tizenOS';
module.exports = [
{
mode: buildMode,
entry: {
main: './src/Main.ts',
},
target: 'node',
module: {
rules: [
{
test: /\.tsx?$/,
include: /src/,
use: [{ loader: 'ts-loader' }]
}
],
},
resolve: {
alias: {
'src': path.resolve(__dirname, 'src'),
'modules': path.resolve(__dirname, 'node_modules'),
'common': path.resolve(__dirname, '../../common/web'),
},
extensions: ['.tsx', '.ts', '.js'],
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist/main'),
},
plugins: [
new CopyWebpackPlugin({
patterns: [
{ from: 'package.json', to: '[name][ext]' },
{ from: 'services.json', to: '[name][ext]' },
],
}),
new webpack.DefinePlugin({
TARGET: JSON.stringify(TARGET)
})
]
}
];

View file

@ -0,0 +1,133 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Ignore VSCode user project settings
.vscode

View file

@ -0,0 +1,3 @@
{
"api_level": "0"
}

View file

@ -0,0 +1,95 @@
# What is FCast?
FCast is a protocol designed for wireless streaming of audio and video content between devices. Unlike alternative protocols like Chromecast and AirPlay, FCast is an open source protocol that allows for custom receiver implementations, enabling third-party developers to create their own receiver devices or integrate the FCast protocol into their own apps.
# Why do I need a receiver?
The FCast receiver is a working receiver implementation compatible with Linux, Windows and MacOS that supports various stream types such as DASH, HLS and mp4.
![FCast receiver running on Linux](images/player-window.png)
# Protocol specification
The protocol specification can be found here https://gitlab.futo.org/videostreaming/fcast/-/wikis/home
# Receiver application
1. Download the latest build for your platform from https://fcast.org/#downloads or build it yourself by following the build instructions.
2. Unzip the archive at your desired location.
3. Run the FCast receiver.
- **MacOS:** Run the `FCast Receiver` application
- **Linux:** Run the `fcast-receiver` application
- **Windows:** Run the `fcast-receiver.exe` application
4. The application will open the main window where you can setup a connection to your sender device.
![Main window](images/main-window.png)
The application will continue to run in the system tray when you close the player or main window. You can exit the application or access other menu options from the tray icon.
There are also command line flags to customize application behavior, some of which include:
* `--no-main-window`: Hide the main window on start
* `--fullscreen`: Start the main window in fullscreen
Use the `--help` flag to see full list of available flags.
# Connecting to the FCast receiver with the video streaming application
## Automatic discovery
1. Open the video streaming application.
2. Open the FCast receiver or restart it.
3. The receiver should now be visible in the casting dialog under "Discovered Devices".
4. If this failed, try manually connecting it. Automatic discovery does not work on all network types.
5. Click start to connect to the device.
6. Start watching content.
## Manual
1. Open the FCast receiver.
2. Find the IP of the device running the receiver.
3. Open the video streaming application.
4. Open the casting dialog.
5. Click add to manually add a device.
6. Select the FCast protocol, enter a descriptive name, the IP you found and port 46899.
7. Click start to connect to the device.
8. Start watching content.
![Manual add dialog in video streaming app](images/Untitled3.png)
# How to build
## Preparing for build
A docker file is provided to setup your build environment. From the root of the repository:
* Build: `docker build -t fcast/receiver-electron-dev:latest receivers/electron/`
* Run: `docker run --rm -it -w /app/receivers/electron --entrypoint='bash' -v .:/app fcast/receiver-electron-dev:latest`
You can then run the following commands to finish setup.
```
npm install
```
## Development
Run the following commands in the `/app/receivers/electron` directory.
* Build: `npm run build`
* Run: `npm run start`
## Packaging
Below are the following platforms currently used for packaging:
* `npm run make -- --platform="darwin" --arch="arm64"`
* `npm run make -- --platform="darwin" --arch="x64"`
* `npm run make -- --platform="win32" --arch="x64"`
* `npm run make -- --platform="linux" --arch="x64"`
* `npm run make -- --platform="linux" --arch="arm64"`
Other platforms and architectures supported by [Electron Forge](https://www.electronforge.io/) might work, but are currently untested.
Packages that will be built from running the above `make` commands:
* Windows: `.msi` and `.zip`
* MacOS: `.dmg` and `.zip`
* Linux: `.deb`, `.rpm` and `.zip`
Package artifacts will be located in `/app/receivers/electron/out/make`

View file

@ -0,0 +1,13 @@
{
"id": "com.futo.fcast.receiver",
"version": "1.0.0",
"vendor": "FUTO",
"type": "web",
"main": "main_window/index.html",
"title": "FCast Receiver",
"appDescription": "FCast Receiver",
"icon": "assets/icons/icon.png",
"largeIcon": "assets/icons/largeIcon.png",
"iconColor": "#0a62f5",
"splashBackground": "assets/images/splash.png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Some files were not shown because too many files have changed in this diff Show more