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

Merge branch 'michael/webos' into 'master'

Michael/webos

See merge request videostreaming/fcast!10
This commit is contained in:
Michael Hollister 2025-02-10 16:07:14 +00:00
commit 8d238ef8b1
124 changed files with 19340 additions and 1759 deletions

4
.gitignore vendored
View file

@ -4,4 +4,6 @@
.vscode
node_modules/
.wrangler/
.wrangler/
receivers/webos/*.ipk

View file

@ -2,6 +2,7 @@ stages:
- buildDockerContainers
- buildAndDeployAndroid
- buildAndDeployElectron
- buildWebOSReceiver
variables:
ANDROID_VERSION_NAME:
@ -13,4 +14,5 @@ variables:
include:
- local: 'receivers/android/.gitlab-ci.yml'
- local: 'receivers/electron/.gitlab-ci.yml'
- local: 'receivers/electron/.gitlab-ci.yml'
- local: 'receivers/webos/.gitlab-ci.yml'

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"); }

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="#019BE7"/>
<path d="M8 12.6L10.5397 15L16 9" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 266 B

View file

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.44873 1.9375C7.13805 0.6875 8.86195 0.6875 9.55246 1.9375L15.7599 13.1875C15.9172 13.4725 16 13.7959 16 14.125C16 14.4541 15.9172 14.7774 15.7599 15.0625C15.6027 15.3475 15.3764 15.5842 15.104 15.7488C14.8316 15.9133 14.5226 16 14.2081 16H1.79314C1.47848 16.0002 1.16932 15.9137 0.896742 15.7492C0.624165 15.5848 0.397785 15.3481 0.240368 15.063C0.0829523 14.7779 5.04209e-05 14.4545 2.29922e-08 14.1253C-5.03749e-05 13.7961 0.0827525 13.4726 0.240081 13.1875L6.44873 1.9375Z" fill="#F97066"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.09511 5C8.31294 5 8.52184 5.08889 8.67587 5.24713C8.8299 5.40536 8.91643 5.61997 8.91643 5.84375V9.21875C8.91643 9.44253 8.8299 9.65714 8.67587 9.81537C8.52184 9.97361 8.31294 10.0625 8.09511 10.0625C7.87728 10.0625 7.66837 9.97361 7.51434 9.81537C7.36031 9.65714 7.27378 9.44253 7.27378 9.21875V5.84375C7.27378 5.61997 7.36031 5.40536 7.51434 5.24713C7.66837 5.08889 7.87728 5 8.09511 5ZM8.09511 14C8.38555 14 8.66409 13.8815 8.86946 13.6705C9.07483 13.4595 9.19021 13.1734 9.19021 12.875C9.19021 12.5766 9.07483 12.2905 8.86946 12.0795C8.66409 11.8685 8.38555 11.75 8.09511 11.75C7.80467 11.75 7.52612 11.8685 7.32075 12.0795C7.11538 12.2905 7 12.5766 7 12.875C7 13.1734 7.11538 13.4595 7.32075 13.6705C7.52612 13.8815 7.80467 14 8.09511 14Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

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

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2.38608C17.525 2.38608 22 6.86108 22 12.3861C22 17.9111 17.525 22.3861 12 22.3861C6.475 22.3861 2 17.9111 2 12.3861C2 6.86108 6.475 2.38608 12 2.38608ZM13.25 7.38608C13.25 6.69858 12.6875 6.13608 12 6.13608C11.3125 6.13608 10.75 6.69858 10.75 7.38608C10.75 8.07358 11.3125 8.63608 12 8.63608C12.6875 8.63608 13.25 8.07358 13.25 7.38608ZM13.25 18.6361V11.1361H10.75V18.6361H13.25Z" fill="#019BE7"/>
</svg>

After

Width:  |  Height:  |  Size: 514 B

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 { EventEmitter } from 'node:events';
import { PlaybackErrorMessage, PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage, VersionMessage, VolumeUpdateMessage } from './Packets';
import { WebSocket } from 'ws';
import * as log4js from "modules/log4js";
import { EventEmitter } from 'events';
import { PlaybackErrorMessage, PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage, VersionMessage, VolumeUpdateMessage } from 'common/Packets';
import { WebSocket } from 'modules/ws';
const logger = log4js.getLogger();
enum SessionState {
@ -61,7 +61,31 @@ export class FCastSession {
const size = 1 + data.length;
const header = Buffer.alloc(4 + 1);
header.writeUint32LE(size, 0);
// Web OS 22 and earlier node versions do not support `writeUint32LE`,
// so manually checking endianness and writing as LE
// @ts-ignore
if (TARGET === 'webOS') {
let uInt32 = new Uint32Array([0x11223344]);
let uInt8 = new Uint8Array(uInt32.buffer);
if(uInt8[0] === 0x44) {
// LE
header[0] = size & 0xFF;
header[1] = size & 0xFF00;
header[2] = size & 0xFF0000;
header[3] = size & 0xFF000000;
} else if (uInt8[0] === 0x11) {
// BE
header[0] = size & 0xFF000000;
header[1] = size & 0xFF0000;
header[2] = size & 0xFF00;
header[3] = size & 0xFF;
}
} else {
header.writeUint32LE(size, 0);
}
header[4] = opcode;
let packet: Buffer;
@ -178,8 +202,15 @@ export class FCastSession {
case Opcode.SetSpeed:
this.emitter.emit("setspeed", JSON.parse(body) as SetSpeedMessage);
break;
case Opcode.Version:
this.emitter.emit("version", JSON.parse(body) as VersionMessage);
break;
case Opcode.Ping:
this.send(Opcode.Pong);
this.emitter.emit("ping");
break;
case Opcode.Pong:
this.emitter.emit("pong");
break;
}
} catch (e) {
@ -204,5 +235,8 @@ export class FCastSession {
this.emitter.on("seek", (body: SeekMessage) => { emitter.emit("seek", body) });
this.emitter.on("setvolume", (body: SetVolumeMessage) => { emitter.emit("setvolume", body) });
this.emitter.on("setspeed", (body: SetSpeedMessage) => { emitter.emit("setspeed", body) });
this.emitter.on("version", (body: VersionMessage) => { emitter.emit("version", body) });
this.emitter.on("ping", () => { emitter.emit("ping") });
this.emitter.on("pong", () => { emitter.emit("pong") });
}
}
}

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,11 +1,12 @@
import * as net from 'net';
import { FCastSession, Opcode } from './FCastSession';
import { EventEmitter } from 'node:events';
import { dialog } from 'electron';
import Main from './Main';
import { FCastSession, Opcode } from 'common/FCastSession';
import { EventEmitter } from 'events';
import { Main, errorHandler } from 'src/Main';
import { v4 as uuidv4 } from 'modules/uuid';
export class TcpListenerService {
public static PORT = 46899;
private static TIMEOUT = 2500;
emitter = new EventEmitter();
@ -35,6 +36,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 +48,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) {
@ -72,6 +58,24 @@ export class TcpListenerService {
session.bindEvents(this.emitter);
this.sessions.push(session);
const connectionId = uuidv4();
let heartbeatRetries = 0;
socket.setTimeout(TcpListenerService.TIMEOUT);
socket.on('timeout', () => {
try {
if (heartbeatRetries > 3) {
Main.logger.warn(`Could not ping device ${socket.remoteAddress}:${socket.remotePort}. Disconnecting...`);
socket.destroy();
}
heartbeatRetries += 1;
session.send(Opcode.Ping);
} catch (e) {
Main.logger.warn(`Error while pinging sender device ${socket.remoteAddress}:${socket.remotePort}.`, e);
socket.destroy();
}
});
socket.on("error", (err) => {
Main.logger.warn(`Error from ${socket.remoteAddress}:${socket.remotePort}.`, err);
socket.destroy();
@ -79,6 +83,7 @@ export class TcpListenerService {
socket.on("data", buffer => {
try {
heartbeatRetries = 0;
session.processBytes(buffer);
} catch (e) {
Main.logger.warn(`Error while handling packet from ${socket.remoteAddress}:${socket.remotePort}.`, e);
@ -91,8 +96,18 @@ export class TcpListenerService {
if (index != -1) {
this.sessions.splice(index, 1);
}
this.emitter.emit('disconnect', { id: connectionId, type: 'tcp', data: { address: socket.remoteAddress, port: socket.remotePort }});
this.emitter.removeListener('ping', pingListener);
});
this.emitter.emit('connect', { id: connectionId, type: 'tcp', data: { address: socket.remoteAddress, port: socket.remotePort }});
const pingListener = (message: any) => {
if (!message) {
this.emitter.emit('ping', { id: connectionId });
}
}
this.emitter.prependListener('ping', pingListener);
try {
Main.logger.info('Sending version');
session.send(Opcode.Version, {version: 2});
@ -100,4 +115,4 @@ export class TcpListenerService {
Main.logger.info('Failed to send version', e);
}
}
}
}

View file

@ -1,8 +1,8 @@
import { FCastSession, Opcode } from './FCastSession';
import { EventEmitter } from 'node:events';
import { dialog } from 'electron';
import Main from './Main';
import { WebSocket, WebSocketServer } from 'ws';
import { FCastSession, Opcode } from 'common/FCastSession';
import { EventEmitter } from 'events';
import { WebSocket, WebSocketServer } from 'modules/ws';
import { Main, errorHandler } from 'src/Main';
import { v4 as uuidv4 } from 'modules/uuid';
export class WebSocketListenerService {
public static PORT = 46898;
@ -45,23 +45,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) {
@ -71,6 +55,8 @@ export class WebSocketListenerService {
session.bindEvents(this.emitter);
this.sessions.push(session);
const connectionId = uuidv4();
socket.on("error", (err) => {
Main.logger.warn(`Error.`, err);
session.close();
@ -96,8 +82,18 @@ export class WebSocketListenerService {
if (index != -1) {
this.sessions.splice(index, 1);
}
this.emitter.emit('disconnect', { id: connectionId, type: 'ws', data: { url: socket.url }});
this.emitter.removeListener('ping', pingListener);
});
this.emitter.emit('connect', { id: connectionId, type: 'ws', data: { url: socket.url }});
const pingListener = (message: any) => {
if (!message) {
this.emitter.emit('ping', { id: connectionId });
}
}
this.emitter.prependListener('ping', pingListener);
try {
Main.logger.info('Sending version');
session.send(Opcode.Version, {version: 2});
@ -105,4 +101,4 @@ export class WebSocketListenerService {
Main.logger.info('Failed to send version');
}
}
}
}

View file

@ -0,0 +1,54 @@
export enum ToastIcon {
INFO,
ERROR,
}
const toastQueue = []
export function toast(message: string, icon: ToastIcon = ToastIcon.INFO, duration: number = 5000) {
toastQueue.push({ message: message, icon: icon, duration: duration });
if (toastQueue.length === 1) {
renderToast(message, icon, duration);
}
}
function renderToast(message: string, icon: ToastIcon = ToastIcon.INFO, duration: number = 5000) {
const toastNotification = document.getElementById('toast-notification');
const toastIcon = document.getElementById('toast-icon');
const toastText = document.getElementById('toast-text');
if (!(toastNotification && toastIcon && toastText)) {
throw 'Toast component could not be initialized';
}
window.setTimeout(() => {
toastNotification.className = 'toast-fade-out';
toastNotification.style.opacity = '0';
toastQueue.shift();
if (toastQueue.length > 0) {
window.setTimeout(() => {
let toast = toastQueue[0];
renderToast(toast.message, toast.icon, toast.duration);
}, 1000);
}
}, duration);
switch (icon) {
case ToastIcon.INFO:
toastIcon.style.backgroundImage = 'url(../assets/icons/app/info.svg)';
break;
case ToastIcon.ERROR:
toastIcon.style.backgroundImage = 'url(../assets/icons/app/error.svg)';
break;
default:
break;
}
toastText.textContent = message;
toastNotification.className = 'toast-fade-in';
toastNotification.style.opacity = '1';
}

View file

@ -0,0 +1,62 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toast, ToastIcon } from '../components/Toast';
declare global {
interface Window {
electronAPI: any;
webOS: any;
webOSDev: any;
targetAPI: any;
}
}
let preloadData: Record<string, any> = {};
// @ts-ignore
if (TARGET === 'electron') {
// @ts-ignore
const electronAPI = __non_webpack_require__('electron');
electronAPI.ipcRenderer.on("device-info", (_event, value: any) => {
preloadData.deviceInfo = value;
})
electronAPI.contextBridge.exposeInMainWorld('targetAPI', {
onDeviceInfo: (callback: any) => electronAPI.ipcRenderer.on("device-info", callback),
onConnect: (callback: any) => electronAPI.ipcRenderer.on("connect", callback),
onDisconnect: (callback: any) => electronAPI.ipcRenderer.on("disconnect", callback),
onPing: (callback: any) => electronAPI.ipcRenderer.on("ping", callback),
getDeviceInfo: () => preloadData.deviceInfo,
});
// @ts-ignore
} else if (TARGET === 'webOS') {
try {
preloadData = {
onDeviceInfoCb: () => { console.log('Main: Callback not set while fetching device info'); },
onConnectCb: (_, value: any) => { console.log('Main: Callback not set while calling onConnect'); },
onDisconnectCb: (_, value: any) => { console.log('Main: Callback not set while calling onDisconnect'); },
onPingCb: (_, value: any) => { console.log('Main: Callback not set while calling onPing'); },
};
window.targetAPI = {
onDeviceInfo: (callback: () => void) => preloadData.onDeviceInfoCb = callback,
onConnect: (callback: (_, value: any) => void) => preloadData.onConnectCb = callback,
onDisconnect: (callback: (_, value: any) => void) => preloadData.onDisconnectCb = callback,
onPing: (callback: (_, value: any) => void) => preloadData.onPingCb = callback,
getDeviceInfo: () => preloadData.deviceInfo,
};
}
catch (err) {
console.error(`Main: preload ${JSON.stringify(err)}`);
toast(`Main: preload ${JSON.stringify(err)}`, ToastIcon.ERROR);
}
} else {
// @ts-ignore
console.log(`Attempting to run FCast player on unsupported target: ${TARGET}`);
}
export {
preloadData
};

View file

@ -0,0 +1,96 @@
import QRCode from 'modules/qrcode';
import { onQRCodeRendered } from 'src/main/Renderer';
import { toast, ToastIcon } from '../components/Toast';
const connectionStatusText = document.getElementById("connection-status-text");
const connectionStatusSpinner = document.getElementById("connection-spinner");
const connectionStatusCheck = document.getElementById("connection-check");
let connections = [];
// Window might be re-created while devices are still connected
window.targetAPI.onPing((_event, value: any) => {
if (value && connections.length === 0) {
connections.push(value.id);
onConnect(value.id);
}
});
window.targetAPI.onDeviceInfo(renderIPsAndQRCode);
window.targetAPI.onConnect((_event, value: any) => {
connections.push(value.id);
onConnect(value);
});
window.targetAPI.onDisconnect((_event, value: any) => {
console.log(`Device disconnected: ${JSON.stringify(value)}`);
const index = connections.indexOf(value.id);
if (index != -1) {
connections.splice(index, 1);
if (connections.length === 0) {
connectionStatusText.textContent = 'Waiting for a connection';
connectionStatusSpinner.style.display = 'inline-block';
connectionStatusCheck.style.display = 'none';
toast("Device disconnected", ToastIcon.INFO);
}
}
});
if(window.targetAPI.getDeviceInfo()) {
console.log("device info already present");
renderIPsAndQRCode();
}
function onConnect(value: any) {
console.log(`Device connected: ${JSON.stringify(value)}`);
connectionStatusText.textContent = 'Connected: Ready to cast';
connectionStatusSpinner.style.display = 'none';
connectionStatusCheck.style.display = 'inline-block';
}
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",
},
(err) => {
if (err) {
console.error(`Error rendering QR Code: ${err}`);
toast(`Error rendering QR Code: ${err}`, ToastIcon.ERROR);
}
else {
console.log(`Rendered QR Code`);
}
});
onQRCodeRendered();
}

View file

@ -0,0 +1,296 @@
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;
}
#connection-status-text, #ips, #automatic-discovery {
margin-top: 20px;
}
#connection-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);
}
}
#connection-check {
/* display: inline-block; */
display: none;
position: relative;
width: 64px;
height: 64px;
margin: 18px;
padding: 10px;
background-color: #019BE7;
border-radius: 50%;
z-index: 0;
}
#connection-check-mark {
position: relative;
top: -10px;
left: -10px;
width: 100%;
height: 100%;
padding: 10px;
animation: check 0.5s cubic-bezier(0.5, 0, 0.5, 1) 1;
background-image: url(../assets/icons/app/checked.svg);
background-size: cover;
background-color: #019BE7;
border-radius: 50%;
z-index: 1;
}
@keyframes check {
0% {
clip-path: inset(0px 64px 0px 0px);
}
100% {
clip-path: inset(0px 0px 0px 0px);
}
}
#toast-notification {
display: flex;
flex-direction: row;
align-items: center;
padding: 16px 20px;
gap: 12px;
position: relative;
top: -200px;
max-width: 70%;
background: #F0F0F0;
border: 3px solid rgba(0, 0, 0, 0.08);
box-shadow: 0px 100px 80px rgba(0, 0, 0, 0.33), 0px 64.8148px 46.8519px rgba(0, 0, 0, 0.250556), 0px 38.5185px 25.4815px rgba(0, 0, 0, 0.200444), 0px 20px 13px rgba(0, 0, 0, 0.165), 0px 8.14815px 6.51852px rgba(0, 0, 0, 0.129556), 0px 1.85185px 3.14815px rgba(0, 0, 0, 0.0794444);
border-radius: 12px;
opacity: 0;
}
#toast-icon {
width: 48px;
height: 48px;
background-image: url(../assets/icons/app/info.svg);
background-size: cover;
flex-shrink: 0;
}
#toast-text {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
font-family: InterVariable;
font-size: 20px;
font-style: normal;
font-weight: 400;
}
.toast-fade-in {
animation: toast-fade-in 1.0s cubic-bezier(0.5, 0, 0.5, 1) 1;
}
.toast-fade-out {
animation: toast-fade-out 1.0s cubic-bezier(0.5, 0, 0.5, 1) 1;
}
@keyframes toast-fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes toast-fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}

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,67 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* 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;
}
}
let preloadData: Record<string, 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') {
preloadData = {
sendPlaybackErrorCb: () => { console.error('Player: Callback "send_playback_error" not set'); },
sendPlaybackUpdateCb: () => { console.error('Player: Callback "send_playback_update" not set'); },
sendVolumeUpdateCb: () => { console.error('Player: Callback "send_volume_update" not set'); },
// onPlayCb: () => { console.error('Player: Callback "play" not set'); },
onPlayCb: undefined,
onPauseCb: () => { console.error('Player: Callback "pause" not set'); },
onResumeCb: () => { console.error('Player: Callback "resume" not set'); },
onSeekCb: () => { console.error('Player: Callback "onseek" not set'); },
onSetVolumeCb: () => { console.error('Player: Callback "setvolume" not set'); },
onSetSpeedCb: () => { console.error('Player: Callback "setspeed" not set'); },
};
window.targetAPI = {
sendPlaybackError: (error: PlaybackErrorMessage) => { preloadData.sendPlaybackErrorCb(error); },
sendPlaybackUpdate: (update: PlaybackUpdateMessage) => { preloadData.sendPlaybackUpdateCb(update); },
sendVolumeUpdate: (update: VolumeUpdateMessage) => { preloadData.sendVolumeUpdateCb(update); },
onPlay: (callback: any) => { preloadData.onPlayCb = callback; },
onPause: (callback: any) => { preloadData.onPauseCb = callback; },
onResume: (callback: any) => { preloadData.onResumeCb = callback; },
onSeek: (callback: any) => { preloadData.onSeekCb = callback; },
onSetVolume: (callback: any) => { preloadData.onSetVolumeCb = callback; },
onSetSpeed: (callback: any) => { preloadData.onSetSpeedCb = callback; }
};
} else {
// @ts-ignore
console.log(`Attempting to run FCast player on unsupported target: ${TARGET}`);
}
export {
preloadData
};

View file

@ -0,0 +1,828 @@
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,
captionsBaseHeightCollapsed,
captionsBaseHeightExpanded,
captionsLineHeight
} 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 playerCtrlDurationSeparator = document.getElementById("durationSeparator");
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;
let captionsBaseHeight = 0;
let captionsContentHeight = 0;
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;
captionsBaseHeight = captionsBaseHeightExpanded;
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);
captionsContentHeight = subtitle.getBoundingClientRect().height - captionsLineHeight;
const captionsHeight = captionsBaseHeight + captionsContentHeight;
if (player.isCaptionsEnabled()) {
videoCaptions.setAttribute("style", `display: block; bottom: ${captionsHeight}px;`);
} else {
videoCaptions.setAttribute("style", `display: none; bottom: ${captionsHeight}px;`);
}
});
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;
// Event can fire after video load and play initialization
if (isLive && playerCtrlLiveBadge.style.display === "none") {
playerCtrlLiveBadge.style.display = "block";
playerCtrlPosition.style.display = "none";
playerCtrlDurationSeparator.style.display = "none";
playerCtrlDuration.style.display = "none";
}
});
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 = (ev) => {
if (videoElement.duration === Infinity) {
isLive = true;
isLivePosition = true;
}
else {
isLive = false;
isLivePosition = false;
}
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) {
const handledCase = targetPlayerCtrlStateUpdate(event);
if (handledCase) {
return;
}
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");
playerCtrlDurationSeparator.setAttribute("style", "display: none");
playerCtrlDuration.setAttribute("style", "display: none");
}
else {
playerCtrlLiveBadge.setAttribute("style", "display: none");
playerCtrlPosition.setAttribute("style", "display: block");
playerCtrlDurationSeparator.setAttribute("style", "display: block");
playerCtrlDuration.setAttribute("style", "display: block");
playerCtrlPosition.textContent = formatDuration(player.getCurrentTime());
playerCtrlDuration.innerHTML = 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 iconSize");
stopUiHideTimer();
break;
case PlayerControlEvent.Play:
playerCtrlAction.setAttribute("class", "pause iconSize");
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 iconSize");
playerCtrlVolumeBarProgress.setAttribute("style", `width: 0px`);
playerCtrlVolumeBarHandle.setAttribute("style", `left: 0px`);
}
else if (player.getVolume() >= 0.5) {
playerCtrlVolume.setAttribute("class", "volume_high iconSize");
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume}px`);
} else {
playerCtrlVolume.setAttribute("class", "volume_low iconSize");
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");
captionsBaseHeight = captionsBaseHeightCollapsed;
const captionsHeight = captionsBaseHeight + captionsContentHeight;
if (player.isCaptionsEnabled()) {
videoCaptions.setAttribute("style", `display: block; transition: bottom 0.2s ease-in-out; bottom: ${captionsHeight}px;`);
} else {
videoCaptions.setAttribute("style", `display: none; bottom: ${captionsHeight}px;`);
}
break;
}
case PlayerControlEvent.UiFadeIn: {
document.body.style.cursor = "default";
playerControls.setAttribute("style", "opacity: 1");
captionsBaseHeight = captionsBaseHeightExpanded;
const captionsHeight = captionsBaseHeight + captionsContentHeight;
if (player.isCaptionsEnabled()) {
videoCaptions.setAttribute("style", `display: block; transition: bottom 0.2s ease-in-out; bottom: ${captionsHeight}px;`);
} else {
videoCaptions.setAttribute("style", `display: none; bottom: ${captionsHeight}px;`);
}
break;
}
case PlayerControlEvent.SetCaptions:
if (player.isCaptionsEnabled()) {
playerCtrlCaptions.setAttribute("class", "captions_on iconSize");
videoCaptions.setAttribute("style", "display: block");
} else {
playerCtrlCaptions.setAttribute("class", "captions_off iconSize");
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:
break;
}
}
function scrubbingMouseUIHandler(e: MouseEvent) {
const progressBarOffset = e.offsetX - playerCtrlProgressBar.offsetLeft;
const progressBarWidth = PlayerCtrlProgressBarInteractiveArea.offsetWidth - (playerCtrlProgressBar.offsetLeft * 2);
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 - playerCtrlProgressBar.offsetLeft;
const progressBarWidth = PlayerCtrlProgressBarInteractiveArea.offsetWidth - (playerCtrlProgressBar.offsetLeft * 2);
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 - playerCtrlVolumeBar.offsetLeft;
const volumeBarWidth = playerCtrlVolumeBarInteractiveArea.offsetWidth - (playerCtrlVolumeBar.offsetLeft * 2);
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);
const handledCase = targetKeyDownEventListener(event);
if (handledCase) {
return;
}
switch (event.code) {
case 'KeyF':
case 'F11':
playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen);
event.preventDefault();
break;
case 'Escape':
playerCtrlStateUpdate(PlayerControlEvent.ExitFullscreen);
event.preventDefault();
break;
case 'ArrowLeft':
skipBack();
event.preventDefault();
break;
case 'ArrowRight':
skipForward();
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':
// Play/pause toggle
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:
break;
}
}
function skipBack() {
player.setCurrentTime(Math.max(player.getCurrentTime() - skipInterval, 0));
}
function skipForward() {
if (!isLivePosition) {
player.setCurrentTime(Math.min(player.getCurrentTime() + skipInterval, player.getDuration()));
}
}
document.addEventListener('keydown', keyDownEventListener);
export {
PlayerControlEvent,
videoElement,
videoCaptions,
playerCtrlProgressBar,
playerCtrlProgressBarBuffer,
playerCtrlProgressBarProgress,
playerCtrlProgressBarHandle,
playerCtrlVolumeBar,
playerCtrlVolumeBarProgress,
playerCtrlVolumeBarHandle,
playerCtrlLiveBadge,
playerCtrlPosition,
playerCtrlDuration,
playerCtrlCaptions,
player,
isLive,
captionsBaseHeight,
captionsLineHeight,
onPlay,
playerCtrlStateUpdate,
formatDuration,
skipBack,
skipForward,
};

View file

@ -0,0 +1,457 @@
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;
}
.iconSize {
width: 24px;
height: 24px;
}
.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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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;
user-select: none;
/* 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,20 @@
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';
import { ToastIcon } from 'common/components/Toast';
const cp = require('child_process');
export default class Main {
export class Main {
static shouldOpenMainWindow = true;
static startFullscreen = false;
static playerWindow: Electron.BrowserWindow;
@ -25,11 +24,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 +74,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 +169,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));
}
});
@ -196,6 +190,10 @@ export default class Main {
l.emitter.on("seek", (message) => Main.playerWindow?.webContents?.send("seek", message));
l.emitter.on("setvolume", (message) => Main.playerWindow?.webContents?.send("setvolume", message));
l.emitter.on("setspeed", (message) => Main.playerWindow?.webContents?.send("setspeed", message));
l.emitter.on('connect', (message) => Main.mainWindow?.webContents?.send('connect', message));
l.emitter.on('disconnect', (message) => Main.mainWindow?.webContents?.send('disconnect', message));
l.emitter.on('ping', (message) => Main.mainWindow?.webContents?.send('ping', message));
l.start();
ipcMain.on('send-playback-error', (event: IpcMainEvent, value: PlaybackErrorMessage) => {
@ -283,116 +281,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 +307,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 +359,60 @@ 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);
Main.mainWindow.webContents.send("toast", { message: err, icon: ToastIcon.ERROR });
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,17 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { contextBridge, ipcRenderer } from 'electron';
import 'common/main/Preload';
import { toast } from 'common/components/Toast';
let deviceInfo;
ipcRenderer.on("device-info", (_event, value) => {
deviceInfo = value;
})
ipcRenderer.on("toast", (_event, value: any) => {
toast(value.message, value.icon, value.duration);
});
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,6 @@
import QRCode from 'qrcode';
import 'common/main/Renderer';
export function onQRCodeRendered() {}
const updateView = document.getElementById("update-view");
const updateViewTitle = document.getElementById("update-view-title");
@ -10,51 +12,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">
@ -21,8 +22,9 @@
</div>
<div id="connection-status">
<div id="waiting-for-connection" class="non-selectable">Waiting for a connection</div>
<div id="spinner" class="lds-ring"><div></div><div></div><div></div><div></div></div>
<div id="connection-status-text" class="non-selectable">Waiting for a connection</div>
<div id="connection-spinner" class="lds-ring"><div></div><div></div><div></div><div></div></div>
<div id="connection-check"><div id="connection-check-mark"></div></div>
</div>
<div id="update-view" class="card">
@ -54,6 +56,10 @@
</div>
</div>
<div id="toast-notification">
<div id="toast-icon"></div>
<div id="toast-text"></div>
</div>
<div id="window-can-be-closed" class="non-selectable">App will continue to run as tray app when the window is closed</div>
</div>
</div>

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,17 @@
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 captionsBaseHeightCollapsed = 75;
const captionsBaseHeightExpanded = 160;
const captionsLineHeight = 34;
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;
export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent): boolean {
let handledCase = 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) {
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();
@ -489,269 +23,49 @@ function playerCtrlStateUpdate(event: PlayerControlEvent) {
}
});
handledCase = true;
break;
}
case PlayerControlEvent.ExitFullscreen:
window.electronAPI.exitFullScreen();
playerCtrlFullscreen.setAttribute("class", "fullscreen_off");
handledCase = true;
break;
default:
break;
}
return handledCase;
}
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): boolean {
let handledCase = false;
switch (event.code) {
case 'KeyF':
case 'F11':
playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen);
event.preventDefault();
handledCase = true;
break;
case 'Escape':
playerCtrlStateUpdate(PlayerControlEvent.ExitFullscreen);
event.preventDefault();
handledCase = true;
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;
}
});
return handledCase
};
export {
captionsBaseHeightCollapsed,
captionsBaseHeightExpanded,
captionsLineHeight,
}

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>
@ -22,9 +23,9 @@
</div>
<div class="leftButtonContainer">
<div id="action" class="play"></div>
<div id="action" class="play iconSize"></div>
<div id="volume" class="volume_high"></div>
<div id="volume" class="volume_high iconSize"></div>
<div class="volumeContainer">
<div id="volumeBar" ref="volumeBar" class="volumeBar" ></div>
<div id="volumeBarProgress" class="volumeBarProgress" ></div>
@ -35,14 +36,15 @@
<div class="positionContainer">
<div id="liveBadge" class="liveBadge" style="display: none">LIVE</div>
<div id="position" class="position">00:00</div>
<div id="duration" class="duration">/&nbsp&nbsp00:00</div>
<div id="durationSeparator" class="duration">/&nbsp&nbsp</div>
<div id="duration" class="duration">00:00</div>
</div>
</div>
<div class="buttonContainer">
<div id="fullscreen" class="fullscreen_on"></div>
<div id="speed" class="speed"></div>
<div id="captions" class="captions_off"></div>
<div id="fullscreen" class="fullscreen_on iconSize"></div>
<div id="speed" class="speed iconSize"></div>
<div id="captions" class="captions_off iconSize"></div>
</div>
<div id="speedMenu" class="speedMenu" style="display: none">

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,29 +1,65 @@
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: {
rules: [
{
test: /\.tsx?$/,
include: /src/,
include: [path.resolve(__dirname, '../common/web'), path.resolve(__dirname, '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: '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',
@ -33,21 +69,44 @@ module.exports = [
rules: [
{
test: /\.tsx?$/,
include: /src/,
include: [path.resolve(__dirname, '../common/web'), path.resolve(__dirname, '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: '../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',
@ -57,17 +116,40 @@ module.exports = [
rules: [
{
test: /\.tsx?$/,
include: /src/,
include: [path.resolve(__dirname, '../common/web'), path.resolve(__dirname, '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/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,26 @@
buildWebOS:
stage: buildWebOSReceiver
image: node:22.11.0-bookworm
tags:
- fcast-instance-runner
before_script:
- cd receivers/webos
- npm install -g @webos-tools/cli
script:
- cd fcast-receiver
- npm install
- npm run build
- cd ../fcast-receiver-service
- npm install
- npm run build
- cd ../
- ares-package fcast-receiver/dist/ fcast-receiver-service/dist/ --no-minify
artifacts:
untracked: false
when: on_success
access: all
expire_in: "30 days"
paths:
- receivers/webos/*.ipk
when: manual

33
receivers/webos/README.md Normal file
View file

@ -0,0 +1,33 @@
# FCast WebOS Receiver
The FCast WebOS Receiver is split into two separate projects `fcast-receiver` for frontend UI and `fcast-receiver-service` for the background network service. The WebOS receiver is supported running on TV devices from WebOS TV 5.0 and later.
The TV receiver player is a simplified player compared to the Electron receiver due to functionality being redundant when using a TV remote control or due to platform limitations (https://gitlab.futo.org/videostreaming/fcast/-/issues/21).
# How to build
From `receivers/webos` directory:
## Prerequisites
```sh
npm install -g @webos-tools/cli
cd fcast-receiver
npm install
cd ../fcast-receiver-service
npm install
cd ../
```
## Build
```sh
cd fcast-receiver
npm run build
cd ../fcast-receiver-service
npm run build
cd ../
```
## Packaging
```sh
ares-package fcast-receiver/dist/ fcast-receiver-service/dist/ --no-minify
```

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,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,42 @@
{
"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",
"patch-package": "^8.0.0",
"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,215 @@
/* 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 'events';
import { ToastIcon } from 'common/components/Toast';
export class Main {
static tcpListenerService: TcpListenerService;
static webSocketListenerService: WebSocketListenerService;
static discoveryService: DiscoveryService;
static logger: log4js.Logger;
static emitter: EventEmitter;
static {
try {
log4js.configure({
appenders: {
console: { type: 'console' },
},
categories: {
default: { appenders: ['console'], level: 'info' },
},
});
Main.logger = log4js.getLogger();
Main.logger.info(`OS: ${process.platform} ${process.arch}`);
const serviceId = 'com.futo.fcast.receiver.service';
const service = new Service(serviceId);
// Service will timeout and casting will disconnect if not forced to be kept alive
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let keepAlive;
service.activityManager.create("keepAlive", function(activity) {
keepAlive = activity;
});
// TODO: Fix issue of emitting events to non-existent subscribers after multiple page changes
const voidCb = (message: any) => { message.respond({ returnValue: true, value: {} }); };
const objectCb = (message: any, value: any) => { message.respond({ returnValue: true, value: value }); };
registerService(service, 'toast', (message: any) => { return objectCb.bind(this, message) });
service.register("getDeviceInfo", (message: any) => {
Main.logger.info("In getDeviceInfo callback");
message.respond({
returnValue: true,
value: { name: os.hostname(), addresses: NetworkService.getAllIPv4Addresses() }
});
});
registerService(service, 'connect', (message: any) => { return objectCb.bind(this, message) });
registerService(service, 'disconnect', (message: any) => { return objectCb.bind(this, message) });
registerService(service, 'ping', (message: any) => { return objectCb.bind(this, message) });
Main.discoveryService = new DiscoveryService();
Main.discoveryService.start();
Main.tcpListenerService = new TcpListenerService();
Main.webSocketListenerService = new WebSocketListenerService();
Main.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 stopClosureCb: any = null;
const seekCb = (message: any, seekMessage: SeekMessage) => { message.respond({ returnValue: true, value: seekMessage }); };
const setVolumeCb = (message: any, volumeMessage: SetVolumeMessage) => { message.respond({ returnValue: true, value: volumeMessage }); };
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...
service.register('play', (message: any) => {
if (message.isSubscription) {
playClosureCb = playCb.bind(this, message);
Main.emitter.on('play', playClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true, playData: playData }});
},
(message: any) => {
Main.logger.info('Canceled play service subscriber');
Main.emitter.off('play', playClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
registerService(service, 'pause', (message: any) => { return voidCb.bind(this, message) });
registerService(service, 'resume', (message: any) => { return voidCb.bind(this, message) });
service.register('stop', (message: any) => {
playData = null;
if (message.isSubscription) {
stopClosureCb = voidCb.bind(this, message);
Main.emitter.on('stop', stopClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true }});
},
(message: any) => {
Main.logger.info('Canceled stop service subscriber');
Main.emitter.off('stop', stopClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
registerService(service, 'seek', (message: any) => { return seekCb.bind(this, message) });
registerService(service, 'setvolume', (message: any) => { return setVolumeCb.bind(this, message) });
registerService(service, 'setspeed', (message: any) => { return setSpeedCb.bind(this, message) });
const listeners = [Main.tcpListenerService, Main.webSocketListenerService];
listeners.forEach(l => {
l.emitter.on("play", async (message) => {
await NetworkService.proxyPlayIfRequired(message);
Main.emitter.emit('play', message);
const appId = 'com.futo.fcast.receiver';
service.call("luna://com.webos.applicationManager/launch", {
'id': appId,
'params': { timestamp: Date.now(), playData: message }
}, (response: any) => {
Main.logger.info(`Launch response: ${JSON.stringify(response)}`);
Main.logger.info(`Relaunching FCast Receiver with args: ${JSON.stringify(message)}`);
});
});
l.emitter.on("pause", () => Main.emitter.emit('pause'));
l.emitter.on("resume", () => Main.emitter.emit('resume'));
l.emitter.on("stop", () => Main.emitter.emit('stop'));
l.emitter.on("seek", (message) => Main.emitter.emit('seek', message));
l.emitter.on("setvolume", (message) => Main.emitter.emit('setvolume', message));
l.emitter.on("setspeed", (message) => Main.emitter.emit('setspeed', message));
l.emitter.on('connect', (message) => Main.emitter.emit('connect', message));
l.emitter.on('disconnect', (message) => Main.emitter.emit('disconnect', message));
l.emitter.on('ping', (message) => Main.emitter.emit('ping', 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);
Main.emitter.emit('toast', { message: `Error initializing service: ${err}`, icon: ToastIcon.ERROR });
}
}
}
export function getComputerName() {
return os.hostname();
}
export async function errorHandler(err: NodeJS.ErrnoException) {
Main.logger.error("Application error:", err);
Main.emitter.emit('toast', { message: err, icon: ToastIcon.ERROR });
}
function registerService(service: Service, method: string, callback: (message: any) => any) {
let callbackRef = null;
service.register(method, (message: any) => {
if (message.isSubscription) {
callbackRef = callback(message);
Main.emitter.on(method, callbackRef);
}
message.respond({ returnValue: true, value: { subscribed: true }});
},
(message: any) => {
Main.logger.info(`Canceled ${method} service subscriber`);
Main.emitter.off(method, callbackRef);
message.respond({ returnValue: true, value: message.payload });
});
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2015",
"module": "ES2015",
"moduleResolution": "node10",
"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" ]
}

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