Initial commit of WebOS receiver
93
receivers/common/assets/fonts/Inter/OFL.txt
Normal 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.
|
72
receivers/common/assets/fonts/Inter/README.txt
Normal 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 aren’t 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.
|
BIN
receivers/common/assets/fonts/Inter/static/Inter-Black.ttf
Normal file
BIN
receivers/common/assets/fonts/Inter/static/Inter-Bold.ttf
Normal file
BIN
receivers/common/assets/fonts/Inter/static/Inter-ExtraBold.ttf
Normal file
BIN
receivers/common/assets/fonts/Inter/static/Inter-ExtraLight.ttf
Normal file
BIN
receivers/common/assets/fonts/Inter/static/Inter-Light.ttf
Normal file
BIN
receivers/common/assets/fonts/Inter/static/Inter-Medium.ttf
Normal file
BIN
receivers/common/assets/fonts/Inter/static/Inter-Regular.ttf
Normal file
BIN
receivers/common/assets/fonts/Inter/static/Inter-SemiBold.ttf
Normal file
BIN
receivers/common/assets/fonts/Inter/static/Inter-Thin.ttf
Normal file
93
receivers/common/assets/fonts/Outfit/OFL.txt
Normal 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.
|
71
receivers/common/assets/fonts/Outfit/README.txt
Normal 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 aren’t 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.
|
BIN
receivers/common/assets/fonts/Outfit/static/Outfit-Black.ttf
Normal file
BIN
receivers/common/assets/fonts/Outfit/static/Outfit-Bold.ttf
Normal file
BIN
receivers/common/assets/fonts/Outfit/static/Outfit-ExtraBold.ttf
Normal file
BIN
receivers/common/assets/fonts/Outfit/static/Outfit-Light.ttf
Normal file
BIN
receivers/common/assets/fonts/Outfit/static/Outfit-Medium.ttf
Normal file
BIN
receivers/common/assets/fonts/Outfit/static/Outfit-Regular.ttf
Normal file
BIN
receivers/common/assets/fonts/Outfit/static/Outfit-SemiBold.ttf
Normal file
BIN
receivers/common/assets/fonts/Outfit/static/Outfit-Thin.ttf
Normal file
18
receivers/common/assets/fonts/inter.css
Normal 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"); }
|
18
receivers/common/assets/fonts/outfit.css
Normal 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"); }
|
BIN
receivers/common/assets/icons/app/icon.png
Normal file
After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.9 KiB |
Before Width: | Height: | Size: 566 B After Width: | Height: | Size: 566 B |
Before Width: | Height: | Size: 566 B After Width: | Height: | Size: 566 B |
Before Width: | Height: | Size: 566 B After Width: | Height: | Size: 566 B |
Before Width: | Height: | Size: 564 B After Width: | Height: | Size: 564 B |
Before Width: | Height: | Size: 456 B After Width: | Height: | Size: 456 B |
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
Before Width: | Height: | Size: 382 B After Width: | Height: | Size: 382 B |
Before Width: | Height: | Size: 380 B After Width: | Height: | Size: 380 B |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 183 B After Width: | Height: | Size: 183 B |
Before Width: | Height: | Size: 181 B After Width: | Height: | Size: 181 B |
Before Width: | Height: | Size: 720 B After Width: | Height: | Size: 720 B |
Before Width: | Height: | Size: 718 B After Width: | Height: | Size: 718 B |
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
Before Width: | Height: | Size: 850 B After Width: | Height: | Size: 850 B |
Before Width: | Height: | Size: 846 B After Width: | Height: | Size: 846 B |
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 1 KiB |
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 1 KiB |
39
receivers/common/web/DiscoveryService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
import * as net from 'net';
|
||||
import * as log4js from "log4js";
|
||||
import * as log4js from "modules/log4js";
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { PlaybackErrorMessage, PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage, VersionMessage, VolumeUpdateMessage } from './Packets';
|
||||
import { WebSocket } from 'ws';
|
||||
import { PlaybackErrorMessage, PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage, VersionMessage, VolumeUpdateMessage } from 'common/Packets';
|
||||
import { WebSocket } from 'modules/ws';
|
||||
const logger = log4js.getLogger();
|
||||
|
||||
enum SessionState {
|
||||
|
@ -205,4 +205,4 @@ export class FCastSession {
|
|||
this.emitter.on("setvolume", (body: SetVolumeMessage) => { emitter.emit("setvolume", body) });
|
||||
this.emitter.on("setspeed", (body: SetSpeedMessage) => { emitter.emit("setspeed", body) });
|
||||
}
|
||||
}
|
||||
}
|
124
receivers/common/web/NetworkService.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -54,4 +54,4 @@ export class VersionMessage {
|
|||
constructor(
|
||||
public version: number,
|
||||
) {}
|
||||
}
|
||||
}
|
|
@ -1,8 +1,7 @@
|
|||
import * as net from 'net';
|
||||
import { FCastSession, Opcode } from './FCastSession';
|
||||
import { FCastSession, Opcode } from 'common/FCastSession';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { dialog } from 'electron';
|
||||
import Main from './Main';
|
||||
import { Main, errorHandler } from 'src/Main';
|
||||
|
||||
export class TcpListenerService {
|
||||
public static PORT = 46899;
|
||||
|
@ -35,6 +34,7 @@ export class TcpListenerService {
|
|||
}
|
||||
|
||||
send(opcode: number, message = null) {
|
||||
// Main.logger.info(`Sending message ${JSON.stringify(message)}`);
|
||||
this.sessions.forEach(session => {
|
||||
try {
|
||||
session.send(opcode, message);
|
||||
|
@ -46,23 +46,7 @@ export class TcpListenerService {
|
|||
}
|
||||
|
||||
private async handleServerError(err: NodeJS.ErrnoException) {
|
||||
Main.logger.error("Server error:", err);
|
||||
|
||||
const restartPrompt = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Failed to start',
|
||||
message: 'The application failed to start properly.',
|
||||
buttons: ['Restart', 'Close'],
|
||||
defaultId: 0,
|
||||
cancelId: 1
|
||||
});
|
||||
|
||||
if (restartPrompt.response === 0) {
|
||||
Main.application.relaunch();
|
||||
Main.application.exit(0);
|
||||
} else {
|
||||
Main.application.exit(0);
|
||||
}
|
||||
errorHandler(err);
|
||||
}
|
||||
|
||||
private handleConnection(socket: net.Socket) {
|
||||
|
@ -100,4 +84,4 @@ export class TcpListenerService {
|
|||
Main.logger.info('Failed to send version', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,8 +1,7 @@
|
|||
import { FCastSession, Opcode } from './FCastSession';
|
||||
import { FCastSession, Opcode } from 'common/FCastSession';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { dialog } from 'electron';
|
||||
import Main from './Main';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
import { WebSocket, WebSocketServer } from 'modules/ws';
|
||||
import { Main, errorHandler } from 'src/Main';
|
||||
|
||||
export class WebSocketListenerService {
|
||||
public static PORT = 46898;
|
||||
|
@ -45,23 +44,7 @@ export class WebSocketListenerService {
|
|||
}
|
||||
|
||||
private async handleServerError(err: NodeJS.ErrnoException) {
|
||||
Main.logger.error("Server error:", err);
|
||||
|
||||
const restartPrompt = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Failed to start',
|
||||
message: 'The application failed to start properly.',
|
||||
buttons: ['Restart', 'Close'],
|
||||
defaultId: 0,
|
||||
cancelId: 1
|
||||
});
|
||||
|
||||
if (restartPrompt.response === 0) {
|
||||
Main.application.relaunch();
|
||||
Main.application.exit(0);
|
||||
} else {
|
||||
Main.application.exit(0);
|
||||
}
|
||||
errorHandler(err);
|
||||
}
|
||||
|
||||
private handleConnection(socket: WebSocket) {
|
||||
|
@ -105,4 +88,4 @@ export class WebSocketListenerService {
|
|||
Main.logger.info('Failed to send version');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
95
receivers/common/web/main/Preload.ts
Normal file
|
@ -0,0 +1,95 @@
|
|||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: any;
|
||||
webOS: any;
|
||||
webOSDev: any;
|
||||
targetAPI: any;
|
||||
}
|
||||
}
|
||||
|
||||
let deviceInfo: any;
|
||||
|
||||
// @ts-ignore
|
||||
if (TARGET === 'electron') {
|
||||
// @ts-ignore
|
||||
const electronAPI = __non_webpack_require__('electron');
|
||||
|
||||
electronAPI.ipcRenderer.on("device-info", (_event, value) => {
|
||||
deviceInfo = value;
|
||||
})
|
||||
|
||||
electronAPI.contextBridge.exposeInMainWorld('targetAPI', {
|
||||
onDeviceInfo: (callback: any) => electronAPI.ipcRenderer.on("device-info", callback),
|
||||
getDeviceInfo: () => deviceInfo,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
} else if (TARGET === 'webOS') {
|
||||
require('lib/webOSTVjs-1.2.10/webOSTV.js');
|
||||
require('lib/webOSTVjs-1.2.10/webOSTV-dev.js');
|
||||
const serviceId = 'com.futo.fcast.receiver.service';
|
||||
let onDeviceInfoCb: any;
|
||||
|
||||
const getDeviceInfoService = window.webOS.service.request(`luna://${serviceId}/`, {
|
||||
method:"getDeviceInfo",
|
||||
parameters: {},
|
||||
onSuccess: (message: any) => {
|
||||
console.log(`Main: getDeviceInfo ${JSON.stringify(message)}`);
|
||||
|
||||
deviceInfo = message.value;
|
||||
onDeviceInfoCb();
|
||||
},
|
||||
onFailure: (message: any) => {
|
||||
console.error(`Main: getDeviceInfo ${JSON.stringify(message)}`);
|
||||
},
|
||||
// onComplete: (message) => {},
|
||||
});
|
||||
|
||||
const playService = window.webOS.service.request(`luna://${serviceId}/`, {
|
||||
method:"play",
|
||||
parameters: {},
|
||||
onSuccess: (message: any) => {
|
||||
if (message.value.subscribed === true) {
|
||||
console.log('Main: Registered play handler with service');
|
||||
}
|
||||
else {
|
||||
console.log(`Main: Playing ${JSON.stringify(message)}`);
|
||||
getDeviceInfoService.cancel();
|
||||
playService.cancel();
|
||||
}
|
||||
},
|
||||
onFailure: (message: any) => {
|
||||
console.error(`Main: play ${JSON.stringify(message)}`);
|
||||
},
|
||||
subscribe: true,
|
||||
resubscribe: true
|
||||
});
|
||||
|
||||
window.targetAPI = {
|
||||
onDeviceInfo: (callback: any) => onDeviceInfoCb = callback,
|
||||
getDeviceInfo: () => deviceInfo,
|
||||
};
|
||||
|
||||
document.addEventListener('webOSRelaunch', (args: any) => {
|
||||
console.log(`Relaunching FCast Receiver with args: ${JSON.stringify(args)}`);
|
||||
|
||||
if (args.playData !== null) {
|
||||
if (getDeviceInfoService !== undefined) {
|
||||
getDeviceInfoService.cancel();
|
||||
}
|
||||
if (playService !== undefined) {
|
||||
playService.cancel();
|
||||
}
|
||||
|
||||
window.open('../player/index.html');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// @ts-ignore
|
||||
console.log(`Attempting to run FCast player on unsupported target: ${TARGET}`);
|
||||
}
|
48
receivers/common/web/main/Renderer.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
|
||||
import QRCode from 'modules/qrcode';
|
||||
|
||||
window.targetAPI.onDeviceInfo(renderIPsAndQRCode);
|
||||
|
||||
if(window.targetAPI.getDeviceInfo()) {
|
||||
console.log("device info already present");
|
||||
renderIPsAndQRCode();
|
||||
}
|
||||
|
||||
function renderIPsAndQRCode() {
|
||||
const value = window.targetAPI.getDeviceInfo();
|
||||
console.log("device info", value);
|
||||
|
||||
const ipsElement = document.getElementById('ips');
|
||||
if (ipsElement) {
|
||||
ipsElement.innerHTML = `IPs<br>${value.addresses.join('<br>')}`;
|
||||
}
|
||||
|
||||
const fcastConfig = {
|
||||
name: value.name,
|
||||
addresses: value.addresses,
|
||||
services: [
|
||||
{ port: 46899, type: 0 }, //TCP
|
||||
{ port: 46898, type: 1 }, //WS
|
||||
]
|
||||
};
|
||||
|
||||
const json = JSON.stringify(fcastConfig);
|
||||
let base64 = btoa(json);
|
||||
base64 = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
const url = `fcast://r/${base64}`;
|
||||
console.log("qr", {json, url, base64});
|
||||
|
||||
const qrCodeElement = document.getElementById('qr-code');
|
||||
QRCode.toCanvas(qrCodeElement, url, {
|
||||
margin: 0,
|
||||
width: 256,
|
||||
color: {
|
||||
dark : "#000000",
|
||||
light : "#ffffff",
|
||||
},
|
||||
errorCorrectionLevel : "M",
|
||||
},
|
||||
(e) => {
|
||||
console.log(`Error rendering QR Code: ${e}`)
|
||||
});
|
||||
}
|
191
receivers/common/web/main/common.css
Normal file
|
@ -0,0 +1,191 @@
|
|||
body, html {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.non-selectable {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
|
||||
background-color: rgba(20, 20, 20, 0.5);
|
||||
padding: 25px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2E2E2E;
|
||||
scrollbar-width: thin;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.card-title-separator {
|
||||
height: 1px;
|
||||
background: #2E2E2E;
|
||||
margin-top: 3px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
#ui-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
gap: 15vw;
|
||||
|
||||
font-family: InterVariable;
|
||||
font-size: 20px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
#title-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#title-text {
|
||||
font-family: Outfit;
|
||||
font-size: 100px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
|
||||
background-image: linear-gradient(180deg, #FFFFFF 5.9%, #D3D3D3 100%);
|
||||
background-clip: text;
|
||||
-webkit-background-clip:text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
#title-icon {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
|
||||
background-image: url(../assets/icons/app/icon.svg);
|
||||
background-size: cover;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
#connection-status {
|
||||
padding: 25px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#main-view {
|
||||
padding: 25px;
|
||||
}
|
||||
|
||||
#manual-connection-info {
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
#manual-connection-info-separator {
|
||||
height: 1px;
|
||||
background: #2E2E2E;
|
||||
margin-top: 3px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
#qr-code {
|
||||
display: flex;
|
||||
margin: 20px auto;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
#scan-to-connect {
|
||||
margin-top: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#waiting-for-connection, #ips, #automatic-discovery {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
#spinner {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#window-can-be-closed {
|
||||
color: #666666;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
margin-bottom: 20px;
|
||||
|
||||
font-family: InterVariable;
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.lds-ring {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
.lds-ring div {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 8px;
|
||||
border: 8px solid #fff;
|
||||
border-radius: 50%;
|
||||
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||
border-color: #fff transparent transparent transparent;
|
||||
}
|
||||
.lds-ring div:nth-child(1) {
|
||||
animation-delay: -0.45s;
|
||||
}
|
||||
.lds-ring div:nth-child(2) {
|
||||
animation-delay: -0.3s;
|
||||
}
|
||||
.lds-ring div:nth-child(3) {
|
||||
animation-delay: -0.15s;
|
||||
}
|
||||
@keyframes lds-ring {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
|
@ -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,
|
240
receivers/common/web/player/Preload.ts
Normal file
|
@ -0,0 +1,240 @@
|
|||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage } from 'common/Packets';
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: any;
|
||||
webOSAPI: any;
|
||||
webOS: any;
|
||||
targetAPI: any;
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
if (TARGET === 'electron') {
|
||||
// @ts-ignore
|
||||
const electronAPI = __non_webpack_require__('electron');
|
||||
|
||||
electronAPI.contextBridge.exposeInMainWorld('targetAPI', {
|
||||
sendPlaybackError: (error: PlaybackErrorMessage) => electronAPI.ipcRenderer.send('send-playback-error', error),
|
||||
sendPlaybackUpdate: (update: PlaybackUpdateMessage) => electronAPI.ipcRenderer.send('send-playback-update', update),
|
||||
sendVolumeUpdate: (update: VolumeUpdateMessage) => electronAPI.ipcRenderer.send('send-volume-update', update),
|
||||
onPlay: (callback: any) => electronAPI.ipcRenderer.on("play", callback),
|
||||
onPause: (callback: any) => electronAPI.ipcRenderer.on("pause", callback),
|
||||
onResume: (callback: any) => electronAPI.ipcRenderer.on("resume", callback),
|
||||
onSeek: (callback: any) => electronAPI.ipcRenderer.on("seek", callback),
|
||||
onSetVolume: (callback: any) => electronAPI.ipcRenderer.on("setvolume", callback),
|
||||
onSetSpeed: (callback: any) => electronAPI.ipcRenderer.on("setspeed", callback)
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
} else if (TARGET === 'webOS') {
|
||||
require('lib/webOSTVjs-1.2.10/webOSTV.js');
|
||||
require('lib/webOSTVjs-1.2.10/webOSTV-dev.js');
|
||||
|
||||
const serviceId = 'com.futo.fcast.receiver.service';
|
||||
let onPlayCb, onPauseCb, onResumeCb;
|
||||
let onSeekCb, onSetVolumeCb, onSetSpeedCb;
|
||||
let playerWindowOpen = false;
|
||||
|
||||
const playService = window.webOS.service.request(`luna://${serviceId}/`, {
|
||||
method:"play",
|
||||
parameters: {},
|
||||
onSuccess: (message: any) => {
|
||||
console.log(JSON.stringify(message));
|
||||
if (message.value.subscribed === true) {
|
||||
console.log('Player: Registered play handler with service');
|
||||
}
|
||||
|
||||
if (message.value.playData !== null) {
|
||||
if (!playerWindowOpen) {
|
||||
playerWindowOpen = true;
|
||||
}
|
||||
|
||||
if (onPlayCb === undefined) {
|
||||
window.webOSAPI.pendingPlay = message.value.playData;
|
||||
}
|
||||
else {
|
||||
onPlayCb(null, message.value.playData);
|
||||
}
|
||||
}
|
||||
},
|
||||
onFailure: (message: any) => {
|
||||
console.error(`Player: play ${JSON.stringify(message)}`);
|
||||
},
|
||||
subscribe: true,
|
||||
resubscribe: true
|
||||
});
|
||||
|
||||
const pauseService = window.webOS.service.request(`luna://${serviceId}/`, {
|
||||
method:"pause",
|
||||
parameters: {},
|
||||
onSuccess: (message: any) => {
|
||||
if (message.value.subscribed === true) {
|
||||
console.log('Player: Registered pause handler with service');
|
||||
}
|
||||
else {
|
||||
onPauseCb();
|
||||
}
|
||||
},
|
||||
onFailure: (message: any) => {
|
||||
console.error(`Player: pause ${JSON.stringify(message)}`);
|
||||
},
|
||||
subscribe: true,
|
||||
resubscribe: true
|
||||
});
|
||||
|
||||
const resumeService = window.webOS.service.request(`luna://${serviceId}/`, {
|
||||
method:"resume",
|
||||
parameters: {},
|
||||
onSuccess: (message: any) => {
|
||||
if (message.value.subscribed === true) {
|
||||
console.log('Player: Registered resume handler with service');
|
||||
}
|
||||
else {
|
||||
onResumeCb();
|
||||
}
|
||||
},
|
||||
onFailure: (message: any) => {
|
||||
console.error(`Player: resume ${JSON.stringify(message)}`);
|
||||
},
|
||||
subscribe: true,
|
||||
resubscribe: true
|
||||
});
|
||||
|
||||
const stopService = window.webOS.service.request(`luna://${serviceId}/`, {
|
||||
method:"stop",
|
||||
parameters: {},
|
||||
onSuccess: (message: any) => {
|
||||
if (message.value.subscribed === true) {
|
||||
console.log('Player: Registered stop handler with service');
|
||||
}
|
||||
else {
|
||||
playerWindowOpen = false;
|
||||
playService.cancel();
|
||||
pauseService.cancel();
|
||||
resumeService.cancel();
|
||||
stopService.cancel();
|
||||
seekService.cancel();
|
||||
setVolumeService.cancel();
|
||||
setSpeedService.cancel();
|
||||
|
||||
// window.open('../main_window/index.html');
|
||||
window.webOS.platformBack();
|
||||
}
|
||||
},
|
||||
onFailure: (message: any) => {
|
||||
console.error(`Player: stop ${JSON.stringify(message)}`);
|
||||
},
|
||||
subscribe: true,
|
||||
resubscribe: true
|
||||
});
|
||||
|
||||
const seekService = window.webOS.service.request(`luna://${serviceId}/`, {
|
||||
method:"seek",
|
||||
parameters: {},
|
||||
onSuccess: (message: any) => {
|
||||
if (message.value.subscribed === true) {
|
||||
console.log('Player: Registered seek handler with service');
|
||||
}
|
||||
else {
|
||||
onSeekCb(null, message.value);
|
||||
}
|
||||
},
|
||||
onFailure: (message: any) => {
|
||||
console.error(`Player: seek ${JSON.stringify(message)}`);
|
||||
},
|
||||
subscribe: true,
|
||||
resubscribe: true
|
||||
});
|
||||
|
||||
const setVolumeService = window.webOS.service.request(`luna://${serviceId}/`, {
|
||||
method:"setvolume",
|
||||
parameters: {},
|
||||
onSuccess: (message: any) => {
|
||||
if (message.value.subscribed === true) {
|
||||
console.log('Player: Registered setvolume handler with service');
|
||||
}
|
||||
else {
|
||||
onSetVolumeCb(null, message.value);
|
||||
}
|
||||
},
|
||||
onFailure: (message: any) => {
|
||||
console.error(`Player: setvolume ${JSON.stringify(message)}`);
|
||||
},
|
||||
subscribe: true,
|
||||
resubscribe: true
|
||||
});
|
||||
|
||||
const setSpeedService = window.webOS.service.request(`luna://${serviceId}/`, {
|
||||
method:"setspeed",
|
||||
parameters: {},
|
||||
onSuccess: (message: any) => {
|
||||
if (message.value.subscribed === true) {
|
||||
console.log('Player: Registered setspeed handler with service');
|
||||
}
|
||||
else {
|
||||
onSetSpeedCb(null, message.value);
|
||||
}
|
||||
},
|
||||
onFailure: (message: any) => {
|
||||
console.error(`Player: setspeed ${JSON.stringify(message)}`);
|
||||
},
|
||||
subscribe: true,
|
||||
resubscribe: true
|
||||
});
|
||||
|
||||
window.targetAPI = {
|
||||
sendPlaybackError: (error: PlaybackErrorMessage) => {
|
||||
window.webOS.service.request(`luna://${serviceId}/`, {
|
||||
method: 'send_playback_error',
|
||||
parameters: { error },
|
||||
onSuccess: () => {},
|
||||
onFailure: (message: any) => {
|
||||
console.error(`Player: send_playback_error ${JSON.stringify(message)}`);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
sendPlaybackUpdate: (update: PlaybackUpdateMessage) => {
|
||||
window.webOS.service.request(`luna://${serviceId}/`, {
|
||||
method: 'send_playback_update',
|
||||
parameters: { update },
|
||||
// onSuccess: (message: any) => {
|
||||
// console.log(`Player: send_playback_update ${JSON.stringify(message)}`);
|
||||
// },
|
||||
onSuccess: () => {},
|
||||
onFailure: (message: any) => {
|
||||
console.error(`Player: send_playback_update ${JSON.stringify(message)}`);
|
||||
},
|
||||
});
|
||||
},
|
||||
sendVolumeUpdate: (update: VolumeUpdateMessage) => {
|
||||
window.webOS.service.request(`luna://${serviceId}/`, {
|
||||
method: 'send_volume_update',
|
||||
parameters: { update },
|
||||
onSuccess: () => {},
|
||||
onFailure: (message: any) => {
|
||||
console.error(`Player: send_volume_update ${JSON.stringify(message)}`);
|
||||
},
|
||||
});
|
||||
},
|
||||
onPlay: (callback: any) => { onPlayCb = callback; },
|
||||
onPause: (callback: any) => { onPauseCb = callback; },
|
||||
onResume: (callback: any) => { onResumeCb = callback; },
|
||||
onSeek: (callback: any) => { onSeekCb = callback; },
|
||||
onSetVolume: (callback: any) => { onSetVolumeCb = callback; },
|
||||
onSetSpeed: (callback: any) => { onSetSpeedCb = callback; }
|
||||
};
|
||||
|
||||
window.webOSAPI = {
|
||||
pendingPlay: null
|
||||
};
|
||||
|
||||
} else {
|
||||
// @ts-ignore
|
||||
console.log(`Attempting to run FCast player on unsupported target: ${TARGET}`);
|
||||
}
|
751
receivers/common/web/player/Renderer.ts
Normal file
|
@ -0,0 +1,751 @@
|
|||
import dashjs from 'modules/dashjs';
|
||||
import Hls, { LevelLoadedData } from 'modules/hls.js';
|
||||
import { PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage } from 'common/Packets';
|
||||
import { Player, PlayerType } from './Player';
|
||||
import { targetPlayerCtrlStateUpdate, targetKeyDownEventListener } from 'src/player/Renderer';
|
||||
|
||||
function formatDuration(duration: number) {
|
||||
const totalSeconds = Math.floor(duration);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(totalSeconds % 60);
|
||||
|
||||
const paddedMinutes = String(minutes).padStart(2, '0');
|
||||
const paddedSeconds = String(seconds).padStart(2, '0');
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${paddedMinutes}:${paddedSeconds}`;
|
||||
} else {
|
||||
return `${paddedMinutes}:${paddedSeconds}`;
|
||||
}
|
||||
}
|
||||
|
||||
function sendPlaybackUpdate(updateState: number) {
|
||||
const updateMessage = new PlaybackUpdateMessage(Date.now(), player.getCurrentTime(), player.getDuration(), updateState, player.getPlaybackRate());
|
||||
|
||||
if (updateMessage.generationTime > lastPlayerUpdateGenerationTime) {
|
||||
lastPlayerUpdateGenerationTime = updateMessage.generationTime;
|
||||
window.targetAPI.sendPlaybackUpdate(updateMessage);
|
||||
}
|
||||
};
|
||||
|
||||
function onPlayerLoad(value: PlayMessage, currentPlaybackRate?: number, currentVolume?: number) {
|
||||
playerCtrlStateUpdate(PlayerControlEvent.Load);
|
||||
|
||||
// Subtitles break when seeking post stream initialization for the DASH player.
|
||||
// Its currently done on player initialization.
|
||||
if (player.playerType === PlayerType.Hls || player.playerType === PlayerType.Html) {
|
||||
if (value.time) {
|
||||
player.setCurrentTime(value.time);
|
||||
}
|
||||
}
|
||||
|
||||
if (value.speed) {
|
||||
player.setPlaybackRate(value.speed);
|
||||
} else if (currentPlaybackRate) {
|
||||
player.setPlaybackRate(currentPlaybackRate);
|
||||
} else {
|
||||
player.setPlaybackRate(1.0);
|
||||
}
|
||||
playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate);
|
||||
|
||||
if (currentVolume) {
|
||||
volumeChangeHandler(currentVolume);
|
||||
}
|
||||
else {
|
||||
// FCast PlayMessage does not contain volume field and could result in the receiver
|
||||
// getting out-of-sync with the sender on 1st playback.
|
||||
volumeChangeHandler(1.0);
|
||||
window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: 1.0 });
|
||||
}
|
||||
|
||||
player.play();
|
||||
}
|
||||
|
||||
// HTML elements
|
||||
const videoElement = document.getElementById("videoPlayer") as HTMLVideoElement;
|
||||
const videoCaptions = document.getElementById("videoCaptions") as HTMLDivElement;
|
||||
|
||||
const playerControls = document.getElementById("controls");
|
||||
|
||||
const playerCtrlAction = document.getElementById("action");
|
||||
const playerCtrlVolume = document.getElementById("volume");
|
||||
|
||||
const playerCtrlProgressBar = document.getElementById("progressBar");
|
||||
const playerCtrlProgressBarBuffer = document.getElementById("progressBarBuffer");
|
||||
const playerCtrlProgressBarProgress = document.getElementById("progressBarProgress");
|
||||
const playerCtrlProgressBarPosition = document.getElementById("progressBarPosition");
|
||||
const playerCtrlProgressBarHandle = document.getElementById("progressBarHandle");
|
||||
const PlayerCtrlProgressBarInteractiveArea = document.getElementById("progressBarInteractiveArea");
|
||||
|
||||
const playerCtrlVolumeBar = document.getElementById("volumeBar");
|
||||
const playerCtrlVolumeBarProgress = document.getElementById("volumeBarProgress");
|
||||
const playerCtrlVolumeBarHandle = document.getElementById("volumeBarHandle");
|
||||
const playerCtrlVolumeBarInteractiveArea = document.getElementById("volumeBarInteractiveArea");
|
||||
|
||||
const playerCtrlLiveBadge = document.getElementById("liveBadge");
|
||||
const playerCtrlPosition = document.getElementById("position");
|
||||
const playerCtrlDuration = document.getElementById("duration");
|
||||
|
||||
const playerCtrlCaptions = document.getElementById("captions");
|
||||
const playerCtrlSpeed = document.getElementById("speed");
|
||||
|
||||
const playerCtrlSpeedMenu = document.getElementById("speedMenu");
|
||||
let playerCtrlSpeedMenuShown = false;
|
||||
|
||||
|
||||
const playbackRates = ["0.25", "0.50", "0.75", "1.00", "1.25", "1.50", "1.75", "2.00"];
|
||||
const playbackUpdateInterval = 1.0;
|
||||
const livePositionDelta = 5.0;
|
||||
const livePositionWindow = livePositionDelta * 4;
|
||||
let player: Player;
|
||||
let playerPrevTime: number = 0;
|
||||
let lastPlayerUpdateGenerationTime = 0;
|
||||
let isLive = false;
|
||||
let isLivePosition = false;
|
||||
|
||||
function onPlay(_event, value: PlayMessage) {
|
||||
console.log("Handle play message renderer", JSON.stringify(value));
|
||||
const currentVolume = player ? player.getVolume() : null;
|
||||
const currentPlaybackRate = player ? player.getPlaybackRate() : null;
|
||||
|
||||
playerPrevTime = 0;
|
||||
lastPlayerUpdateGenerationTime = 0;
|
||||
isLive = false;
|
||||
isLivePosition = false;
|
||||
|
||||
if (player) {
|
||||
if (player.getSource() === value.url) {
|
||||
if (value.time) {
|
||||
if (Math.abs(value.time - player.getCurrentTime()) < 5000) {
|
||||
console.warn(`Skipped changing video URL because URL and time is (nearly) unchanged: ${value.url}, ${player.getSource()}, ${formatDuration(value.time)}, ${formatDuration(player.getCurrentTime())}`);
|
||||
} else {
|
||||
console.info(`Skipped changing video URL because URL is the same, but time was changed, seeking instead: ${value.url}, ${player.getSource()}, ${formatDuration(value.time)}, ${formatDuration(player.getCurrentTime())}`);
|
||||
|
||||
player.setCurrentTime(value.time);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
player.destroy();
|
||||
}
|
||||
|
||||
if ((value.url || value.content) && value.container && videoElement) {
|
||||
if (value.container === 'application/dash+xml') {
|
||||
console.log("Loading dash player");
|
||||
const dashPlayer = dashjs.MediaPlayer().create();
|
||||
player = new Player(PlayerType.Dash, dashPlayer);
|
||||
|
||||
dashPlayer.extend("RequestModifier", () => {
|
||||
return {
|
||||
modifyRequestHeader: function (xhr) {
|
||||
if (value.headers) {
|
||||
for (const [key, val] of Object.entries(value.headers)) {
|
||||
xhr.setRequestHeader(key, val);
|
||||
}
|
||||
}
|
||||
|
||||
return xhr;
|
||||
}
|
||||
};
|
||||
}, true);
|
||||
|
||||
// Player event handlers
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PLAYING, () => { sendPlaybackUpdate(1); playerCtrlStateUpdate(PlayerControlEvent.Play); });
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PAUSED, () => { sendPlaybackUpdate(2); playerCtrlStateUpdate(PlayerControlEvent.Pause); });
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ENDED, () => { sendPlaybackUpdate(0) });
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_TIME_UPDATED, () => {
|
||||
playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate);
|
||||
|
||||
if (Math.abs(dashPlayer.time() - playerPrevTime) >= playbackUpdateInterval) {
|
||||
sendPlaybackUpdate(dashPlayer.isPaused() ? 2 : 1);
|
||||
playerPrevTime = dashPlayer.time();
|
||||
}
|
||||
});
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_RATE_CHANGED, () => { sendPlaybackUpdate(dashPlayer.isPaused() ? 2 : 1) });
|
||||
|
||||
// Buffering UI update when paused
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PROGRESS, () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); });
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_VOLUME_CHANGED, () => {
|
||||
const updateVolume = dashPlayer.isMuted() ? 0 : dashPlayer.getVolume();
|
||||
playerCtrlStateUpdate(PlayerControlEvent.VolumeChange);
|
||||
window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume });
|
||||
});
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.ERROR, (data) => { window.targetAPI.sendPlaybackError({
|
||||
message: `DashJS ERROR: ${JSON.stringify(data)}`
|
||||
})});
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ERROR, (data) => { window.targetAPI.sendPlaybackError({
|
||||
message: `DashJS PLAYBACK_ERROR: ${JSON.stringify(data)}`
|
||||
})});
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.STREAM_INITIALIZED, () => { onPlayerLoad(value, currentPlaybackRate, currentVolume); });
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.CUE_ENTER, (e: any) => {
|
||||
const subtitle = document.createElement("p")
|
||||
subtitle.setAttribute("id", "subtitle-" + e.cueID)
|
||||
|
||||
subtitle.textContent = e.text;
|
||||
videoCaptions.appendChild(subtitle);
|
||||
});
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.CUE_EXIT, (e: any) => {
|
||||
document.getElementById("subtitle-" + e.cueID)?.remove();
|
||||
});
|
||||
|
||||
dashPlayer.updateSettings({
|
||||
// debug: {
|
||||
// logLevel: dashjs.LogLevel.LOG_LEVEL_INFO
|
||||
// },
|
||||
streaming: {
|
||||
text: {
|
||||
dispatchForManualRendering: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (value.content) {
|
||||
dashPlayer.initialize(videoElement, `data:${value.container};base64,` + window.btoa(value.content), true, value.time);
|
||||
// dashPlayer.initialize(videoElement, "https://dash.akamaized.net/akamai/test/caption_test/ElephantsDream/elephants_dream_480p_heaac5_1_https.mpd", true);
|
||||
} else {
|
||||
// value.url = 'https://dash.akamaized.net/akamai/bbb_30fps/bbb_30fps.mpd';
|
||||
dashPlayer.initialize(videoElement, value.url, true, value.time);
|
||||
}
|
||||
|
||||
} else if ((value.container === 'application/vnd.apple.mpegurl' || value.container === 'application/x-mpegURL') && !videoElement.canPlayType(value.container)) {
|
||||
console.log("Loading hls player");
|
||||
|
||||
const config = {
|
||||
xhrSetup: function (xhr: XMLHttpRequest) {
|
||||
if (value.headers) {
|
||||
for (const [key, val] of Object.entries(value.headers)) {
|
||||
xhr.setRequestHeader(key, val);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const hlsPlayer = new Hls(config);
|
||||
|
||||
hlsPlayer.on(Hls.Events.ERROR, (eventName, data) => {
|
||||
window.targetAPI.sendPlaybackError({
|
||||
message: `HLS player error: ${JSON.stringify(data)}`
|
||||
});
|
||||
});
|
||||
|
||||
hlsPlayer.on(Hls.Events.LEVEL_LOADED, (eventName, level: LevelLoadedData) => {
|
||||
isLive = level.details.live;
|
||||
isLivePosition = isLive ? true : false;
|
||||
});
|
||||
|
||||
player = new Player(PlayerType.Hls, videoElement, hlsPlayer);
|
||||
|
||||
// value.url = "https://devstreaming-cdn.apple.com/videos/streaming/examples/adv_dv_atmos/main.m3u8?ref=developerinsider.co";
|
||||
hlsPlayer.loadSource(value.url);
|
||||
hlsPlayer.attachMedia(videoElement);
|
||||
// hlsPlayer.subtitleDisplay = true;
|
||||
|
||||
} else {
|
||||
console.log("Loading html player");
|
||||
player = new Player(PlayerType.Html, videoElement);
|
||||
|
||||
videoElement.src = value.url;
|
||||
videoElement.load();
|
||||
}
|
||||
|
||||
// Player event handlers
|
||||
if (player.playerType === PlayerType.Hls || player.playerType === PlayerType.Html) {
|
||||
videoElement.onplay = () => { sendPlaybackUpdate(1); playerCtrlStateUpdate(PlayerControlEvent.Play); };
|
||||
videoElement.onpause = () => { sendPlaybackUpdate(2); playerCtrlStateUpdate(PlayerControlEvent.Pause); };
|
||||
videoElement.onended = () => { sendPlaybackUpdate(0) };
|
||||
videoElement.ontimeupdate = () => {
|
||||
playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate);
|
||||
|
||||
if (Math.abs(videoElement.currentTime - playerPrevTime) >= playbackUpdateInterval) {
|
||||
sendPlaybackUpdate(videoElement.paused ? 2 : 1);
|
||||
playerPrevTime = videoElement.currentTime;
|
||||
}
|
||||
};
|
||||
// Buffering UI update when paused
|
||||
videoElement.onprogress = () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); };
|
||||
videoElement.onratechange = () => { sendPlaybackUpdate(videoElement.paused ? 2 : 1) };
|
||||
videoElement.onvolumechange = () => {
|
||||
const updateVolume = videoElement.muted ? 0 : videoElement.volume;
|
||||
playerCtrlStateUpdate(PlayerControlEvent.VolumeChange);
|
||||
window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume });
|
||||
};
|
||||
|
||||
videoElement.onerror = (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) => {
|
||||
console.error("Player error", {source, lineno, colno, error});
|
||||
};
|
||||
|
||||
videoElement.onloadedmetadata = () => { onPlayerLoad(value, currentPlaybackRate, currentVolume); };
|
||||
}
|
||||
}
|
||||
|
||||
// Sender generated event handlers
|
||||
window.targetAPI.onPause(() => { player.pause(); });
|
||||
window.targetAPI.onResume(() => { player.play(); });
|
||||
window.targetAPI.onSeek((_event, value: SeekMessage) => { player.setCurrentTime(value.time); });
|
||||
window.targetAPI.onSetVolume((_event, value: SetVolumeMessage) => { volumeChangeHandler(value.volume); });
|
||||
window.targetAPI.onSetSpeed((_event, value: SetSpeedMessage) => { player.setPlaybackRate(value.speed); playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate); });
|
||||
};
|
||||
|
||||
window.targetAPI.onPlay(onPlay);
|
||||
|
||||
let scrubbing = false;
|
||||
let volumeChanging = false;
|
||||
|
||||
enum PlayerControlEvent {
|
||||
Load,
|
||||
Pause,
|
||||
Play,
|
||||
VolumeChange,
|
||||
TimeUpdate,
|
||||
UiFadeOut,
|
||||
UiFadeIn,
|
||||
SetCaptions,
|
||||
ToggleSpeedMenu,
|
||||
SetPlaybackRate,
|
||||
ToggleFullscreen,
|
||||
ExitFullscreen,
|
||||
}
|
||||
|
||||
// UI update handlers
|
||||
function playerCtrlStateUpdate(event: PlayerControlEvent) {
|
||||
switch (event) {
|
||||
case PlayerControlEvent.Load: {
|
||||
playerCtrlProgressBarBuffer.setAttribute("style", "width: 0px");
|
||||
playerCtrlProgressBarProgress.setAttribute("style", "width: 0px");
|
||||
playerCtrlProgressBarHandle.setAttribute("style", `left: ${playerCtrlProgressBar.offsetLeft}px`);
|
||||
|
||||
const volume = Math.round(player.getVolume() * playerCtrlVolumeBar.offsetWidth);
|
||||
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
|
||||
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume + 8}px`);
|
||||
|
||||
if (isLive) {
|
||||
playerCtrlLiveBadge.setAttribute("style", "display: block");
|
||||
playerCtrlPosition.setAttribute("style", "display: none");
|
||||
playerCtrlDuration.setAttribute("style", "display: none");
|
||||
}
|
||||
else {
|
||||
playerCtrlLiveBadge.setAttribute("style", "display: none");
|
||||
playerCtrlPosition.setAttribute("style", "display: block");
|
||||
playerCtrlDuration.setAttribute("style", "display: block");
|
||||
playerCtrlPosition.textContent = formatDuration(player.getCurrentTime());
|
||||
playerCtrlDuration.innerHTML = `/  ${formatDuration(player.getDuration())}`;
|
||||
}
|
||||
|
||||
if (player.isCaptionsSupported()) {
|
||||
playerCtrlCaptions.setAttribute("style", "display: block");
|
||||
videoCaptions.setAttribute("style", "display: block");
|
||||
}
|
||||
else {
|
||||
playerCtrlCaptions.setAttribute("style", "display: none");
|
||||
videoCaptions.setAttribute("style", "display: none");
|
||||
player.enableCaptions(false);
|
||||
}
|
||||
playerCtrlStateUpdate(PlayerControlEvent.SetCaptions);
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.Pause:
|
||||
playerCtrlAction.setAttribute("class", "play");
|
||||
stopUiHideTimer();
|
||||
break;
|
||||
|
||||
case PlayerControlEvent.Play:
|
||||
playerCtrlAction.setAttribute("class", "pause");
|
||||
startUiHideTimer();
|
||||
break;
|
||||
|
||||
case PlayerControlEvent.VolumeChange: {
|
||||
// console.log(`VolumeChange: isMute ${player.isMuted()}, volume: ${player.getVolume()}`);
|
||||
const volume = Math.round(player.getVolume() * playerCtrlVolumeBar.offsetWidth);
|
||||
|
||||
if (player.isMuted()) {
|
||||
playerCtrlVolume.setAttribute("class", "mute");
|
||||
playerCtrlVolumeBarProgress.setAttribute("style", `width: 0px`);
|
||||
playerCtrlVolumeBarHandle.setAttribute("style", `left: 0px`);
|
||||
}
|
||||
else if (player.getVolume() >= 0.5) {
|
||||
playerCtrlVolume.setAttribute("class", "volume_high");
|
||||
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
|
||||
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume}px`);
|
||||
} else {
|
||||
playerCtrlVolume.setAttribute("class", "volume_low");
|
||||
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
|
||||
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume}px`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.TimeUpdate: {
|
||||
// console.log(`TimeUpdate: Position: ${player.getCurrentTime()}, Duration: ${player.getDuration()}`);
|
||||
|
||||
if (isLive) {
|
||||
if (isLivePosition && player.getDuration() - player.getCurrentTime() > livePositionWindow) {
|
||||
isLivePosition = false;
|
||||
playerCtrlLiveBadge.setAttribute("style", `background-color: #595959`);
|
||||
}
|
||||
else if (!isLivePosition && player.getDuration() - player.getCurrentTime() <= livePositionWindow) {
|
||||
isLivePosition = true;
|
||||
playerCtrlLiveBadge.setAttribute("style", `background-color: red`);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLivePosition) {
|
||||
playerCtrlProgressBarProgress.setAttribute("style", `width: ${playerCtrlProgressBar.offsetWidth}px`);
|
||||
playerCtrlProgressBarHandle.setAttribute("style", `left: ${playerCtrlProgressBar.offsetWidth + playerCtrlProgressBar.offsetLeft}px`);
|
||||
}
|
||||
else {
|
||||
const buffer = Math.round((player.getBufferLength() / player.getDuration()) * playerCtrlProgressBar.offsetWidth);
|
||||
const progress = Math.round((player.getCurrentTime() / player.getDuration()) * playerCtrlProgressBar.offsetWidth);
|
||||
const handle = progress + playerCtrlProgressBar.offsetLeft;
|
||||
|
||||
playerCtrlProgressBarBuffer.setAttribute("style", `width: ${buffer}px`);
|
||||
playerCtrlProgressBarProgress.setAttribute("style", `width: ${progress}px`);
|
||||
playerCtrlProgressBarHandle.setAttribute("style", `left: ${handle}px`);
|
||||
|
||||
playerCtrlPosition.textContent = formatDuration(player.getCurrentTime());
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.UiFadeOut:
|
||||
document.body.style.cursor = "none";
|
||||
playerControls.setAttribute("style", "opacity: 0");
|
||||
|
||||
if (player.isCaptionsEnabled()) {
|
||||
videoCaptions.setAttribute("style", "display: block; bottom: 75px;");
|
||||
} else {
|
||||
videoCaptions.setAttribute("style", "display: none; bottom: 75px;");
|
||||
}
|
||||
|
||||
|
||||
break;
|
||||
|
||||
case PlayerControlEvent.UiFadeIn:
|
||||
document.body.style.cursor = "default";
|
||||
playerControls.setAttribute("style", "opacity: 1");
|
||||
|
||||
if (player.isCaptionsEnabled()) {
|
||||
videoCaptions.setAttribute("style", "display: block; bottom: 160px;");
|
||||
} else {
|
||||
videoCaptions.setAttribute("style", "display: none; bottom: 160px;");
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case PlayerControlEvent.SetCaptions:
|
||||
if (player.isCaptionsEnabled()) {
|
||||
playerCtrlCaptions.setAttribute("class", "captions_on");
|
||||
videoCaptions.setAttribute("style", "display: block");
|
||||
} else {
|
||||
playerCtrlCaptions.setAttribute("class", "captions_off");
|
||||
videoCaptions.setAttribute("style", "display: none");
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case PlayerControlEvent.ToggleSpeedMenu: {
|
||||
if (playerCtrlSpeedMenuShown) {
|
||||
playerCtrlSpeedMenu.setAttribute("style", "display: none");
|
||||
} else {
|
||||
playerCtrlSpeedMenu.setAttribute("style", "display: block");
|
||||
}
|
||||
|
||||
playerCtrlSpeedMenuShown = !playerCtrlSpeedMenuShown;
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.SetPlaybackRate: {
|
||||
const rate = player.getPlaybackRate().toFixed(2);
|
||||
const entryElement = document.getElementById(`speedMenuEntry_${rate}_enabled`);
|
||||
|
||||
playbackRates.forEach(r => {
|
||||
const entry = document.getElementById(`speedMenuEntry_${r}_enabled`);
|
||||
entry.setAttribute("style", "opacity: 0");
|
||||
});
|
||||
|
||||
// Ignore updating GUI for custom rates
|
||||
if (entryElement !== null) {
|
||||
entryElement.setAttribute("style", "opacity: 1");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
targetPlayerCtrlStateUpdate(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function scrubbingMouseUIHandler(e: MouseEvent) {
|
||||
const progressBarOffset = e.offsetX - 8;
|
||||
const progressBarWidth = PlayerCtrlProgressBarInteractiveArea.offsetWidth - 16;
|
||||
let time = isLive ? Math.round((1 - (progressBarOffset / progressBarWidth)) * player.getDuration()) : Math.round((progressBarOffset / progressBarWidth) * player.getDuration());
|
||||
time = Math.min(player.getDuration(), Math.max(0.0, time));
|
||||
|
||||
if (scrubbing && isLive && e.buttons === 1) {
|
||||
isLivePosition = false;
|
||||
playerCtrlLiveBadge.setAttribute("style", `background-color: #595959`);
|
||||
}
|
||||
|
||||
const livePrefix = isLive && Math.floor(time) !== 0 ? "-" : "";
|
||||
playerCtrlProgressBarPosition.textContent = isLive ? `${livePrefix}${formatDuration(time)}` : formatDuration(time);
|
||||
|
||||
let offset = e.offsetX - (playerCtrlProgressBarPosition.offsetWidth / 2);
|
||||
offset = Math.min(PlayerCtrlProgressBarInteractiveArea.offsetWidth - (playerCtrlProgressBarPosition.offsetWidth / 1), Math.max(8, offset));
|
||||
playerCtrlProgressBarPosition.setAttribute("style", `display: block; left: ${offset}px`);
|
||||
}
|
||||
|
||||
// Receiver generated event handlers
|
||||
playerCtrlAction.onclick = () => {
|
||||
if (player.isPaused()) {
|
||||
player.play();
|
||||
} else {
|
||||
player.pause();
|
||||
}
|
||||
};
|
||||
|
||||
playerCtrlVolume.onclick = () => { player.setMute(!player.isMuted()); };
|
||||
|
||||
PlayerCtrlProgressBarInteractiveArea.onmousedown = (e: MouseEvent) => { scrubbing = true; scrubbingMouseHandler(e) };
|
||||
PlayerCtrlProgressBarInteractiveArea.onmouseup = () => { scrubbing = false; };
|
||||
PlayerCtrlProgressBarInteractiveArea.onmouseenter = (e: MouseEvent) => {
|
||||
if (e.buttons === 0) {
|
||||
volumeChanging = false;
|
||||
}
|
||||
|
||||
scrubbingMouseUIHandler(e);
|
||||
};
|
||||
PlayerCtrlProgressBarInteractiveArea.onmouseleave = () => { playerCtrlProgressBarPosition.setAttribute("style", "display: none"); };
|
||||
PlayerCtrlProgressBarInteractiveArea.onmousemove = (e: MouseEvent) => { scrubbingMouseHandler(e) };
|
||||
|
||||
function scrubbingMouseHandler(e: MouseEvent) {
|
||||
const progressBarOffset = e.offsetX - 8;
|
||||
const progressBarWidth = PlayerCtrlProgressBarInteractiveArea.offsetWidth - 16;
|
||||
let time = Math.round((progressBarOffset / progressBarWidth) * player.getDuration());
|
||||
time = Math.min(player.getDuration(), Math.max(0.0, time));
|
||||
|
||||
if (scrubbing && e.buttons === 1) {
|
||||
player.setCurrentTime(time);
|
||||
}
|
||||
|
||||
scrubbingMouseUIHandler(e);
|
||||
}
|
||||
|
||||
playerCtrlVolumeBarInteractiveArea.onmousedown = (e: MouseEvent) => { volumeChanging = true; volumeChangeMouseHandler(e) };
|
||||
playerCtrlVolumeBarInteractiveArea.onmouseup = () => { volumeChanging = false; };
|
||||
playerCtrlVolumeBarInteractiveArea.onmouseenter = (e: MouseEvent) => {
|
||||
if (e.buttons === 0) {
|
||||
scrubbing = false;
|
||||
}
|
||||
};
|
||||
playerCtrlVolumeBarInteractiveArea.onmousemove = (e: MouseEvent) => { volumeChangeMouseHandler(e) };
|
||||
playerCtrlVolumeBarInteractiveArea.onwheel = (e: WheelEvent) => {
|
||||
const delta = -e.deltaY;
|
||||
|
||||
if (delta > 0 ) {
|
||||
volumeChangeHandler(Math.min(player.getVolume() + volumeIncrement, 1));
|
||||
} else if (delta < 0) {
|
||||
volumeChangeHandler(Math.max(player.getVolume() - volumeIncrement, 0));
|
||||
}
|
||||
};
|
||||
|
||||
function volumeChangeMouseHandler(e: MouseEvent) {
|
||||
if (volumeChanging && e.buttons === 1) {
|
||||
const volumeBarOffsetX = e.offsetX - 8;
|
||||
const volumeBarWidth = playerCtrlVolumeBarInteractiveArea.offsetWidth - 16;
|
||||
const volume = volumeBarOffsetX / volumeBarWidth;
|
||||
volumeChangeHandler(volume);
|
||||
}
|
||||
}
|
||||
|
||||
function volumeChangeHandler(volume: number) {
|
||||
if (!player.isMuted() && volume <= 0) {
|
||||
player.setMute(true);
|
||||
}
|
||||
else if (player.isMuted() && volume > 0) {
|
||||
player.setMute(false);
|
||||
}
|
||||
|
||||
player.setVolume(volume);
|
||||
}
|
||||
|
||||
playerCtrlLiveBadge.onclick = () => { setLivePosition(); };
|
||||
|
||||
function setLivePosition() {
|
||||
if (!isLivePosition) {
|
||||
isLivePosition = true;
|
||||
|
||||
player.setCurrentTime(player.getDuration() - livePositionDelta);
|
||||
playerCtrlLiveBadge.setAttribute("style", `background-color: red`);
|
||||
|
||||
if (player.isPaused()) {
|
||||
player.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playerCtrlCaptions.onclick = () => { player.enableCaptions(!player.isCaptionsEnabled()); playerCtrlStateUpdate(PlayerControlEvent.SetCaptions); };
|
||||
playerCtrlSpeed.onclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu); };
|
||||
|
||||
playbackRates.forEach(r => {
|
||||
const entry = document.getElementById(`speedMenuEntry_${r}`);
|
||||
entry.onclick = () => {
|
||||
player.setPlaybackRate(parseFloat(r));
|
||||
playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate);
|
||||
playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu);
|
||||
};
|
||||
});
|
||||
|
||||
videoElement.onclick = () => {
|
||||
if (!playerCtrlSpeedMenuShown) {
|
||||
if (player.isPaused()) {
|
||||
player.play();
|
||||
} else {
|
||||
player.pause();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Component hiding
|
||||
let uiHideTimer = null;
|
||||
let uiVisible = true;
|
||||
|
||||
function startUiHideTimer() {
|
||||
if (uiHideTimer === null) {
|
||||
uiHideTimer = window.setTimeout(() => {
|
||||
uiHideTimer = null;
|
||||
uiVisible = false;
|
||||
playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function stopUiHideTimer() {
|
||||
if (uiHideTimer) {
|
||||
window.clearTimeout(uiHideTimer);
|
||||
uiHideTimer = null;
|
||||
}
|
||||
|
||||
if (!uiVisible) {
|
||||
uiVisible = true;
|
||||
playerCtrlStateUpdate(PlayerControlEvent.UiFadeIn);
|
||||
}
|
||||
}
|
||||
|
||||
document.onmouseout = () => {
|
||||
if (uiHideTimer) {
|
||||
window.clearTimeout(uiHideTimer);
|
||||
uiHideTimer = null;
|
||||
}
|
||||
|
||||
uiVisible = false;
|
||||
playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut);
|
||||
}
|
||||
|
||||
document.onmousemove = () => {
|
||||
stopUiHideTimer();
|
||||
|
||||
if (player && !player.isPaused()) {
|
||||
startUiHideTimer();
|
||||
}
|
||||
};
|
||||
|
||||
window.onresize = () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); };
|
||||
|
||||
// Listener for hiding speed menu when clicking outside element
|
||||
document.addEventListener('click', (event: MouseEvent) => {
|
||||
const node = event.target as Node;
|
||||
if (playerCtrlSpeedMenuShown && !playerCtrlSpeed.contains(node) && !playerCtrlSpeedMenu.contains(node)){
|
||||
playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu);
|
||||
}
|
||||
});
|
||||
|
||||
// Add the keydown event listener to the document
|
||||
const skipInterval = 10;
|
||||
const volumeIncrement = 0.1;
|
||||
|
||||
function keyDownEventListener(event: any) {
|
||||
// console.log("KeyDown", event);
|
||||
|
||||
switch (event.code) {
|
||||
case 'KeyF':
|
||||
case 'F11':
|
||||
playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen);
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'Escape':
|
||||
playerCtrlStateUpdate(PlayerControlEvent.ExitFullscreen);
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
// Skip back
|
||||
player.setCurrentTime(Math.max(player.getCurrentTime() - skipInterval, 0));
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
// Skip forward
|
||||
if (!isLivePosition) {
|
||||
player.setCurrentTime(Math.min(player.getCurrentTime() + skipInterval, player.getDuration()));
|
||||
}
|
||||
event.preventDefault();
|
||||
break;
|
||||
case "Home":
|
||||
player.setCurrentTime(0);
|
||||
event.preventDefault();
|
||||
break;
|
||||
case "End":
|
||||
if (isLive) {
|
||||
setLivePosition();
|
||||
}
|
||||
else {
|
||||
player.setCurrentTime(player.getDuration());
|
||||
}
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'KeyK':
|
||||
case 'Space':
|
||||
case 'Enter':
|
||||
// Pause/Continue
|
||||
if (player.isPaused()) {
|
||||
player.play();
|
||||
} else {
|
||||
player.pause();
|
||||
}
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'KeyM':
|
||||
// Mute toggle
|
||||
player.setMute(!player.isMuted());
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
// Volume up
|
||||
volumeChangeHandler(Math.min(player.getVolume() + volumeIncrement, 1));
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
// Volume down
|
||||
volumeChangeHandler(Math.max(player.getVolume() - volumeIncrement, 0));
|
||||
break;
|
||||
default:
|
||||
targetKeyDownEventListener(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', keyDownEventListener);
|
||||
|
||||
export {
|
||||
videoElement,
|
||||
PlayerControlEvent,
|
||||
onPlay,
|
||||
playerCtrlStateUpdate,
|
||||
};
|
468
receivers/common/web/player/common.css
Normal file
|
@ -0,0 +1,468 @@
|
|||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: black;
|
||||
color: white;
|
||||
width: 100vw;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
#videoPlayer {
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
*:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
|
||||
/* height: 100%; */
|
||||
height: 120px;
|
||||
width: 100%;
|
||||
/* background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 100%); */
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.0) 35%);
|
||||
|
||||
background-size: 100% 300px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: bottom;
|
||||
|
||||
opacity: 1;
|
||||
transition: opacity 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.volumeContainer {
|
||||
position: relative;
|
||||
height: 24px;
|
||||
width: 92px;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.volumeBar {
|
||||
position: absolute;
|
||||
/* left: 12px; */
|
||||
left: 8px;
|
||||
top: 10px;
|
||||
height: 4px;
|
||||
/* width: 72px; */
|
||||
width: 76px;
|
||||
background-color: #999999;
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.volumeBarInteractiveArea {
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
/* left: 8px; */
|
||||
top: 0px;
|
||||
height: 24px;
|
||||
width: 92px;
|
||||
/* width: 84px; */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.volumeBarHandle {
|
||||
position: absolute;
|
||||
left: 84px;
|
||||
top: 4px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
/* background-color: #ffffff; */
|
||||
background-color: #c9c9c9;
|
||||
box-shadow: 0px 32px 64px 0px rgba(0, 0, 0, 0.56), 0px 2px 21px 0px rgba(0, 0, 0, 0.55);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.volumeBarProgress {
|
||||
position: absolute;
|
||||
/* left: 12px; */
|
||||
left: 8px;
|
||||
top: 10px;
|
||||
height: 4px;
|
||||
width: 76px;
|
||||
/* background-color: #ffffff; */
|
||||
background-color: #c9c9c9;
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.progressBarContainer {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
height: 4px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.progressBarInteractiveArea {
|
||||
position: absolute;
|
||||
/* bottom: 60px; */
|
||||
/* left: 24px; */
|
||||
/* right: 24px; */
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
left: 0px;
|
||||
bottom: 0px;
|
||||
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
z-index: 999;
|
||||
}
|
||||
.progressBarChapterContainer {
|
||||
position: absolute;
|
||||
bottom: 73px;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
height: 4px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
/* position: absolute; */
|
||||
position: relative;
|
||||
/* bottom: 70px; */
|
||||
/* left: 24px; */
|
||||
/* right: 24px; */
|
||||
left: 8px;
|
||||
width: calc(100% - 16px);
|
||||
height: 4px;
|
||||
background-color: #99999945;
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.progressBarBuffer {
|
||||
/* position: absolute; */
|
||||
position: relative;
|
||||
/* bottom: 70px; */
|
||||
/* left: 24px; */
|
||||
left: 8px;
|
||||
bottom: 4px;
|
||||
height: 4px;
|
||||
background-color: #D9D9D945;
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.progressBarProgress {
|
||||
/* position: absolute; */
|
||||
position: relative;
|
||||
/* bottom: 70px; */
|
||||
/* left: 24px; */
|
||||
left: 8px;
|
||||
bottom: 8px;
|
||||
height: 4px;
|
||||
width: 0px;
|
||||
background-color: #019BE7;
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.progressBarPosition {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 25px;
|
||||
padding: 2px 5px;
|
||||
|
||||
font-family: InterVariable;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
|
||||
border-radius: 3px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.progressBarHandle {
|
||||
position: absolute;
|
||||
/* bottom: 70px; */
|
||||
bottom: 10px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: -8px;
|
||||
margin-bottom: -8px;
|
||||
background-color: #019BE7;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.positionContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
|
||||
font-family: InterVariable;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
|
||||
.position {
|
||||
margin-right: 10px;
|
||||
vertical-align: bottom;
|
||||
|
||||
color: #c9c9c9;
|
||||
}
|
||||
|
||||
.duration {
|
||||
opacity: 0.6;
|
||||
|
||||
color: #c9c9c9;
|
||||
}
|
||||
|
||||
.liveBadge {
|
||||
background-color: red;
|
||||
/* margin-top: -2px; */
|
||||
/* padding: 5px 5px; */
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
/* margin-left: 10px; */
|
||||
margin-right: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.play {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
background-image: url("../assets/icons/player/icon24_play.svg");
|
||||
transition: background-image 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.play:hover {
|
||||
background-image: url("../assets/icons/player/icon24_play_active.svg");
|
||||
}
|
||||
|
||||
.pause {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
background-image: url("../assets/icons/player/icon24_pause.svg");
|
||||
transition: background-image 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.pause:hover {
|
||||
background-image: url("../assets/icons/player/icon24_pause_active.svg");
|
||||
}
|
||||
|
||||
.volume_high {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
background-image: url("../assets/icons/player/icon24_volume_more_50pct.svg");
|
||||
transition: background-image 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.volume_high:hover {
|
||||
background-image: url("../assets/icons/player/icon24_volume_more_50pct_active.svg");
|
||||
}
|
||||
|
||||
.volume_low {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
background-image: url("../assets/icons/player/icon24_volume_less_50pct.svg");
|
||||
transition: background-image 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.volume_low:hover {
|
||||
background-image: url("../assets/icons/player/icon24_volume_less_50pct_active.svg");
|
||||
}
|
||||
|
||||
.mute {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
background-image: url("../assets/icons/player/icon24_mute.svg");
|
||||
transition: background-image 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.mute:hover {
|
||||
background-image: url("../assets/icons/player/icon24_mute_active.svg");
|
||||
}
|
||||
|
||||
.speed {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
|
||||
background-image: url("../assets/icons/player/icon24_speed.svg");
|
||||
transition: background-image 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.speed:hover {
|
||||
background-image: url("../assets/icons/player/icon24_speed_active.svg");
|
||||
}
|
||||
|
||||
.captions_off {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
|
||||
background-image: url("../assets/icons/player/icon24_cc_off.svg");
|
||||
transition: background-image 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.captions_off:hover {
|
||||
background-image: url("../assets/icons/player/icon24_cc_off_active.svg");
|
||||
}
|
||||
|
||||
.captions_on {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
|
||||
background-image: url("../assets/icons/player/icon24_cc_on.svg");
|
||||
transition: background-image 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.captions_on:hover {
|
||||
background-image: url("../assets/icons/player/icon24_cc_on_active.svg");
|
||||
}
|
||||
|
||||
.leftButtonContainer {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
height: 24px;
|
||||
/* width: calc(50% - 24px); */
|
||||
right: 160px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
overflow: hidden;
|
||||
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
height: 24px;
|
||||
/* width: calc(50% - 24px); */
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.captionsContainer {
|
||||
/* display: none; */
|
||||
position: relative;
|
||||
/* top: -200px; */
|
||||
bottom: 160px;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
|
||||
font-family: InterVariable;
|
||||
font-size: 28px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
padding: 0px 5px;
|
||||
|
||||
width: fit-content;
|
||||
|
||||
transition: bottom 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.speedMenu {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
right: 60px;
|
||||
height: calc(55vh);
|
||||
max-height: 368px;
|
||||
|
||||
background-color: #141414;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2E2E2E;
|
||||
scrollbar-width: thin;
|
||||
overflow: auto;
|
||||
|
||||
font-family: InterVariable;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
|
||||
box-shadow: 0px 1.852px 3.148px 0px rgba(0, 0, 0, 0.06), 0px 8.148px 6.519px 0px rgba(0, 0, 0, 0.10), 0px 20px 13px 0px rgba(0, 0, 0, 0.13), 0px 38.519px 25.481px 0px rgba(0, 0, 0, 0.15), 0px 64.815px 46.852px 0px rgba(0, 0, 0, 0.19), 0px 100px 80px 0px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.speedMenuTitle {
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.speedMenuEntry {
|
||||
display: flex;
|
||||
padding: 10px 15px;
|
||||
}
|
||||
|
||||
.speedMenuEntry:hover {
|
||||
cursor: pointer;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.speedMenuSeparator {
|
||||
height: 1px;
|
||||
background: #2E2E2E;
|
||||
margin-top: 3px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.speedMenuEntryEnabled {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 10px;
|
||||
|
||||
background-image: url("../assets/icons/player/icon24_check_thin.svg");
|
||||
background-size: cover;
|
||||
opacity: 0;
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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");
|
||||
}
|
|
@ -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");
|
||||
}
|
229
receivers/electron/package-lock.json
generated
|
@ -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": {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { app } from 'electron';
|
||||
import Main from './Main';
|
||||
import { Main } from './Main';
|
||||
|
||||
await Main.main(app);
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,21 +1,19 @@
|
|||
import { BrowserWindow, ipcMain, IpcMainEvent, nativeImage, Tray, Menu, dialog } from 'electron';
|
||||
import { TcpListenerService } from './TcpListenerService';
|
||||
import { PlayMessage, PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage } from './Packets';
|
||||
import { DiscoveryService } from './DiscoveryService';
|
||||
import { PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage } from 'common/Packets';
|
||||
import { DiscoveryService } from 'common/DiscoveryService';
|
||||
import { TcpListenerService } from 'common/TcpListenerService';
|
||||
import { WebSocketListenerService } from 'common/WebSocketListenerService';
|
||||
import { NetworkService } from 'common/NetworkService';
|
||||
import { Opcode } from 'common/FCastSession';
|
||||
import { Updater } from './Updater';
|
||||
import { WebSocketListenerService } from './WebSocketListenerService';
|
||||
import { Opcode } from './FCastSession';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
import * as url from 'url';
|
||||
import * as log4js from "log4js";
|
||||
import { AddressInfo } from 'ws';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
const cp = require('child_process');
|
||||
|
||||
export default class Main {
|
||||
export class Main {
|
||||
static shouldOpenMainWindow = true;
|
||||
static startFullscreen = false;
|
||||
static playerWindow: Electron.BrowserWindow;
|
||||
|
@ -25,11 +23,6 @@ export default class Main {
|
|||
static webSocketListenerService: WebSocketListenerService;
|
||||
static discoveryService: DiscoveryService;
|
||||
static tray: Tray;
|
||||
static key: string = null;
|
||||
static cert: string = null;
|
||||
static proxyServer: http.Server;
|
||||
static proxyServerAddress: AddressInfo;
|
||||
static proxiedFiles: Map<string, { url: string, headers: { [key: string]: string } }> = new Map();
|
||||
static logger: log4js.Logger;
|
||||
|
||||
private static toggleMainWindow() {
|
||||
|
@ -80,7 +73,7 @@ export default class Main {
|
|||
}
|
||||
|
||||
private static createTray() {
|
||||
const icon = (process.platform === 'win32') ? path.join(__dirname, 'icon.ico') : path.join(__dirname, 'icon.png');
|
||||
const icon = (process.platform === 'win32') ? path.join(__dirname, 'assets/icons/app/icon.ico') : path.join(__dirname, 'assets/icons/app/icon.png');
|
||||
const trayicon = nativeImage.createFromPath(icon)
|
||||
const tray = new Tray(trayicon.resize({ width: 16 }));
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
|
@ -175,13 +168,13 @@ export default class Main {
|
|||
|
||||
Main.playerWindow.loadFile(path.join(__dirname, 'player/index.html'));
|
||||
Main.playerWindow.on('ready-to-show', async () => {
|
||||
Main.playerWindow?.webContents?.send("play", await Main.proxyPlayIfRequired(message));
|
||||
Main.playerWindow?.webContents?.send("play", await NetworkService.proxyPlayIfRequired(message));
|
||||
});
|
||||
Main.playerWindow.on('closed', () => {
|
||||
Main.playerWindow = null;
|
||||
});
|
||||
} else {
|
||||
Main.playerWindow?.webContents?.send("play", await Main.proxyPlayIfRequired(message));
|
||||
Main.playerWindow?.webContents?.send("play", await NetworkService.proxyPlayIfRequired(message));
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -283,116 +276,6 @@ export default class Main {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
private static setupProxyServer(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
Main.logger.info(`Proxy server starting`);
|
||||
|
||||
const port = 0;
|
||||
Main.proxyServer = http.createServer((req, res) => {
|
||||
Main.logger.info(`Request received`);
|
||||
const requestUrl = `http://${req.headers.host}${req.url}`;
|
||||
|
||||
const proxyInfo = Main.proxiedFiles.get(requestUrl);
|
||||
|
||||
if (!proxyInfo) {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const omitHeaders = new Set([
|
||||
'host',
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'te',
|
||||
'trailers',
|
||||
'transfer-encoding',
|
||||
'upgrade'
|
||||
]);
|
||||
|
||||
const filteredHeaders = Object.fromEntries(Object.entries(req.headers)
|
||||
.filter(([key]) => !omitHeaders.has(key.toLowerCase()))
|
||||
.map(([key, value]) => [key, Array.isArray(value) ? value.join(', ') : value]));
|
||||
|
||||
const parsedUrl = url.parse(proxyInfo.url);
|
||||
const options: http.RequestOptions = {
|
||||
... parsedUrl,
|
||||
method: req.method,
|
||||
headers: { ...filteredHeaders, ...proxyInfo.headers }
|
||||
};
|
||||
|
||||
const proxyReq = http.request(options, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res, { end: true });
|
||||
});
|
||||
|
||||
req.pipe(proxyReq, { end: true });
|
||||
proxyReq.on('error', (e) => {
|
||||
Main.logger.error(`Problem with request: ${e.message}`);
|
||||
res.writeHead(500);
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
Main.proxyServer.on('error', e => {
|
||||
reject(e);
|
||||
});
|
||||
Main.proxyServer.listen(port, '127.0.0.1', () => {
|
||||
Main.proxyServerAddress = Main.proxyServer.address() as AddressInfo;
|
||||
Main.logger.info(`Proxy server running at http://127.0.0.1:${Main.proxyServerAddress.port}/`);
|
||||
resolve();
|
||||
});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static streamingMediaTypes = [
|
||||
"application/vnd.apple.mpegurl",
|
||||
"application/x-mpegURL",
|
||||
"application/dash+xml"
|
||||
];
|
||||
|
||||
static async proxyPlayIfRequired(message: PlayMessage): Promise<PlayMessage> {
|
||||
if (message.headers && message.url && !Main.streamingMediaTypes.find(v => v === message.container.toLocaleLowerCase())) {
|
||||
return { ...message, url: await Main.proxyFile(message.url, message.headers) };
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
static async proxyFile(url: string, headers: { [key: string]: string }): Promise<string> {
|
||||
if (!Main.proxyServer) {
|
||||
await Main.setupProxyServer();
|
||||
}
|
||||
|
||||
const proxiedUrl = `http://127.0.0.1:${Main.proxyServerAddress.port}/${uuidv4()}`;
|
||||
Main.logger.info("Proxied url", { proxiedUrl, url, headers });
|
||||
Main.proxiedFiles.set(proxiedUrl, { url: url, headers: headers });
|
||||
return proxiedUrl;
|
||||
}
|
||||
|
||||
static getAllIPv4Addresses() {
|
||||
const interfaces = os.networkInterfaces();
|
||||
const ipv4Addresses: string[] = [];
|
||||
|
||||
for (const interfaceName in interfaces) {
|
||||
const addresses = interfaces[interfaceName];
|
||||
if (!addresses) continue;
|
||||
|
||||
for (const addressInfo of addresses) {
|
||||
if (addressInfo.family === 'IPv4' && !addressInfo.internal) {
|
||||
ipv4Addresses.push(addressInfo.address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ipv4Addresses;
|
||||
}
|
||||
|
||||
static openMainWindow() {
|
||||
if (Main.mainWindow) {
|
||||
Main.mainWindow.focus();
|
||||
|
@ -419,7 +302,7 @@ export default class Main {
|
|||
Main.mainWindow.show();
|
||||
|
||||
Main.mainWindow.on('ready-to-show', () => {
|
||||
Main.mainWindow.webContents.send("device-info", {name: os.hostname(), addresses: Main.getAllIPv4Addresses()});
|
||||
Main.mainWindow.webContents.send("device-info", {name: os.hostname(), addresses: NetworkService.getAllIPv4Addresses()});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -471,3 +354,59 @@ export default class Main {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getComputerName() {
|
||||
switch (process.platform) {
|
||||
case "win32":
|
||||
return process.env.COMPUTERNAME;
|
||||
case "darwin":
|
||||
return cp.execSync("scutil --get ComputerName").toString().trim();
|
||||
case "linux": {
|
||||
let hostname: string;
|
||||
|
||||
// Some distro's don't work with `os.hostname()`, but work with `hostnamectl` and vice versa...
|
||||
try {
|
||||
hostname = os.hostname();
|
||||
}
|
||||
catch (err) {
|
||||
Main.logger.warn('Error fetching hostname, trying different method...');
|
||||
Main.logger.warn(err);
|
||||
|
||||
try {
|
||||
hostname = cp.execSync("hostnamectl hostname").toString().trim();
|
||||
}
|
||||
catch (err2) {
|
||||
Main.logger.warn('Error fetching hostname again, using generic name...');
|
||||
Main.logger.warn(err2);
|
||||
|
||||
hostname = 'linux device';
|
||||
}
|
||||
}
|
||||
|
||||
return hostname;
|
||||
}
|
||||
|
||||
default:
|
||||
return os.hostname();
|
||||
}
|
||||
}
|
||||
|
||||
export async function errorHandler(err: NodeJS.ErrnoException) {
|
||||
Main.logger.error("Application error:", err);
|
||||
|
||||
const restartPrompt = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Failed to start',
|
||||
message: 'The application failed to start properly.',
|
||||
buttons: ['Restart', 'Close'],
|
||||
defaultId: 0,
|
||||
cancelId: 1
|
||||
});
|
||||
|
||||
if (restartPrompt.response === 0) {
|
||||
Main.application.relaunch();
|
||||
Main.application.exit(0);
|
||||
} else {
|
||||
Main.application.exit(0);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,17 +1,12 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
let deviceInfo;
|
||||
ipcRenderer.on("device-info", (_event, value) => {
|
||||
deviceInfo = value;
|
||||
})
|
||||
import 'common/main/Preload';
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
updaterProgress: () => ipcRenderer.invoke('updater-progress'),
|
||||
onDeviceInfo: (callback) => ipcRenderer.on("device-info", callback),
|
||||
onUpdateAvailable: (callback) => ipcRenderer.on("update-available", callback),
|
||||
onUpdateAvailable: (callback: any) => ipcRenderer.on("update-available", callback),
|
||||
sendDownloadRequest: () => ipcRenderer.send('send-download-request'),
|
||||
onDownloadComplete: (callback) => ipcRenderer.on("download-complete", callback),
|
||||
onDownloadFailed: (callback) => ipcRenderer.on("download-failed", callback),
|
||||
onDownloadComplete: (callback: any) => ipcRenderer.on("download-complete", callback),
|
||||
onDownloadFailed: (callback: any) => ipcRenderer.on("download-failed", callback),
|
||||
sendRestartRequest: () => ipcRenderer.send('send-restart-request'),
|
||||
getDeviceInfo: () => deviceInfo,
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import QRCode from 'qrcode';
|
||||
import 'common/main/Renderer';
|
||||
|
||||
const updateView = document.getElementById("update-view");
|
||||
const updateViewTitle = document.getElementById("update-view-title");
|
||||
|
@ -10,51 +10,6 @@ const progressBar = document.getElementById("progress-bar");
|
|||
const progressBarProgress = document.getElementById("progress-bar-progress");
|
||||
|
||||
let updaterProgressUIUpdateTimer = null;
|
||||
window.electronAPI.onDeviceInfo(renderIPsAndQRCode);
|
||||
|
||||
if(window.electronAPI.getDeviceInfo()) {
|
||||
console.log("device info already present");
|
||||
renderIPsAndQRCode();
|
||||
}
|
||||
|
||||
function renderIPsAndQRCode() {
|
||||
const value = window.electronAPI.getDeviceInfo();
|
||||
console.log("device info", value);
|
||||
|
||||
const ipsElement = document.getElementById('ips');
|
||||
if (ipsElement) {
|
||||
ipsElement.innerHTML = `IPs<br>${value.addresses.join('<br>')}`;
|
||||
}
|
||||
|
||||
const fcastConfig = {
|
||||
name: value.name,
|
||||
addresses: value.addresses,
|
||||
services: [
|
||||
{ port: 46899, type: 0 }, //TCP
|
||||
{ port: 46898, type: 1 }, //WS
|
||||
]
|
||||
};
|
||||
|
||||
const json = JSON.stringify(fcastConfig);
|
||||
let base64 = btoa(json);
|
||||
base64 = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
const url = `fcast://r/${base64}`;
|
||||
console.log("qr", {json, url, base64});
|
||||
|
||||
const qrCodeElement = document.getElementById('qr-code');
|
||||
QRCode.toCanvas(qrCodeElement, url, {
|
||||
margin: 0,
|
||||
width: 256,
|
||||
color: {
|
||||
dark : "#000000",
|
||||
light : "#ffffff",
|
||||
},
|
||||
errorCorrectionLevel : "M",
|
||||
},
|
||||
(e) => {
|
||||
console.log(`Error rendering QR Code: ${e}`)
|
||||
});
|
||||
}
|
||||
|
||||
window.electronAPI.onUpdateAvailable(() => {
|
||||
console.log(`Received UpdateAvailable event`);
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
});
|
||||
|
|
|
@ -1,483 +1,11 @@
|
|||
import dashjs from 'dashjs';
|
||||
import Hls, { LevelLoadedData } from 'hls.js';
|
||||
import { PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage } from '../Packets';
|
||||
import { Player, PlayerType } from './Player';
|
||||
import { videoElement, PlayerControlEvent, playerCtrlStateUpdate } from 'common/player/Renderer';
|
||||
|
||||
function formatDuration(duration: number) {
|
||||
const totalSeconds = Math.floor(duration);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(totalSeconds % 60);
|
||||
|
||||
const paddedMinutes = String(minutes).padStart(2, '0');
|
||||
const paddedSeconds = String(seconds).padStart(2, '0');
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${paddedMinutes}:${paddedSeconds}`;
|
||||
} else {
|
||||
return `${paddedMinutes}:${paddedSeconds}`;
|
||||
}
|
||||
}
|
||||
|
||||
function sendPlaybackUpdate(updateState: number) {
|
||||
const updateMessage = new PlaybackUpdateMessage(Date.now(), player.getCurrentTime(), player.getDuration(), updateState, player.getPlaybackRate());
|
||||
|
||||
if (updateMessage.generationTime > lastPlayerUpdateGenerationTime) {
|
||||
lastPlayerUpdateGenerationTime = updateMessage.generationTime;
|
||||
window.electronAPI.sendPlaybackUpdate(updateMessage);
|
||||
}
|
||||
};
|
||||
|
||||
function onPlayerLoad(value: PlayMessage, currentPlaybackRate?: number, currentVolume?: number) {
|
||||
playerCtrlStateUpdate(PlayerControlEvent.Load);
|
||||
|
||||
// Subtitles break when seeking post stream initialization for the DASH player.
|
||||
// Its currently done on player initialization.
|
||||
if (player.playerType === PlayerType.Hls || player.playerType === PlayerType.Html) {
|
||||
if (value.time) {
|
||||
player.setCurrentTime(value.time);
|
||||
}
|
||||
}
|
||||
|
||||
if (value.speed) {
|
||||
player.setPlaybackRate(value.speed);
|
||||
} else if (currentPlaybackRate) {
|
||||
player.setPlaybackRate(currentPlaybackRate);
|
||||
} else {
|
||||
player.setPlaybackRate(1.0);
|
||||
}
|
||||
playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate);
|
||||
|
||||
if (currentVolume) {
|
||||
volumeChangeHandler(currentVolume);
|
||||
}
|
||||
else {
|
||||
// FCast PlayMessage does not contain volume field and could result in the receiver
|
||||
// getting out-of-sync with the sender on 1st playback.
|
||||
volumeChangeHandler(1.0);
|
||||
window.electronAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: 1.0 });
|
||||
}
|
||||
|
||||
player.play();
|
||||
}
|
||||
|
||||
// HTML elements
|
||||
const videoElement = document.getElementById("videoPlayer") as HTMLVideoElement;
|
||||
const videoCaptions = document.getElementById("videoCaptions") as HTMLDivElement;
|
||||
|
||||
const playerControls = document.getElementById("controls");
|
||||
|
||||
const playerCtrlAction = document.getElementById("action");
|
||||
const playerCtrlVolume = document.getElementById("volume");
|
||||
|
||||
const playerCtrlProgressBar = document.getElementById("progressBar");
|
||||
const playerCtrlProgressBarBuffer = document.getElementById("progressBarBuffer");
|
||||
const playerCtrlProgressBarProgress = document.getElementById("progressBarProgress");
|
||||
const playerCtrlProgressBarPosition = document.getElementById("progressBarPosition");
|
||||
const playerCtrlProgressBarHandle = document.getElementById("progressBarHandle");
|
||||
const PlayerCtrlProgressBarInteractiveArea = document.getElementById("progressBarInteractiveArea");
|
||||
|
||||
const playerCtrlVolumeBar = document.getElementById("volumeBar");
|
||||
const playerCtrlVolumeBarProgress = document.getElementById("volumeBarProgress");
|
||||
const playerCtrlVolumeBarHandle = document.getElementById("volumeBarHandle");
|
||||
const playerCtrlVolumeBarInteractiveArea = document.getElementById("volumeBarInteractiveArea");
|
||||
|
||||
const playerCtrlLiveBadge = document.getElementById("liveBadge");
|
||||
const playerCtrlPosition = document.getElementById("position");
|
||||
const playerCtrlDuration = document.getElementById("duration");
|
||||
|
||||
const playerCtrlCaptions = document.getElementById("captions");
|
||||
const playerCtrlSpeed = document.getElementById("speed");
|
||||
const playerCtrlFullscreen = document.getElementById("fullscreen");
|
||||
playerCtrlFullscreen.onclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); };
|
||||
videoElement.ondblclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); };
|
||||
|
||||
const playerCtrlSpeedMenu = document.getElementById("speedMenu");
|
||||
let playerCtrlSpeedMenuShown = false;
|
||||
|
||||
|
||||
const playbackRates = ["0.25", "0.50", "0.75", "1.00", "1.25", "1.50", "1.75", "2.00"];
|
||||
const playbackUpdateInterval = 1.0;
|
||||
const livePositionDelta = 5.0;
|
||||
const livePositionWindow = livePositionDelta * 4;
|
||||
let player: Player;
|
||||
let playerPrevTime: number = 0;
|
||||
let lastPlayerUpdateGenerationTime = 0;
|
||||
let isLive = false;
|
||||
let isLivePosition = false;
|
||||
|
||||
|
||||
window.electronAPI.onPlay((_event, value: PlayMessage) => {
|
||||
console.log("Handle play message renderer", JSON.stringify(value));
|
||||
const currentVolume = player ? player.getVolume() : null;
|
||||
const currentPlaybackRate = player ? player.getPlaybackRate() : null;
|
||||
|
||||
playerPrevTime = 0;
|
||||
lastPlayerUpdateGenerationTime = 0;
|
||||
isLive = false;
|
||||
isLivePosition = false;
|
||||
|
||||
if (player) {
|
||||
if (player.getSource() === value.url) {
|
||||
if (value.time) {
|
||||
if (Math.abs(value.time - player.getCurrentTime()) < 5000) {
|
||||
console.warn(`Skipped changing video URL because URL and time is (nearly) unchanged: ${value.url}, ${player.getSource()}, ${formatDuration(value.time)}, ${formatDuration(player.getCurrentTime())}`);
|
||||
} else {
|
||||
console.info(`Skipped changing video URL because URL is the same, but time was changed, seeking instead: ${value.url}, ${player.getSource()}, ${formatDuration(value.time)}, ${formatDuration(player.getCurrentTime())}`);
|
||||
|
||||
player.setCurrentTime(value.time);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
player.destroy();
|
||||
}
|
||||
|
||||
if ((value.url || value.content) && value.container && videoElement) {
|
||||
if (value.container === 'application/dash+xml') {
|
||||
console.log("Loading dash player");
|
||||
const dashPlayer = dashjs.MediaPlayer().create();
|
||||
player = new Player(PlayerType.Dash, dashPlayer);
|
||||
|
||||
dashPlayer.extend("RequestModifier", () => {
|
||||
return {
|
||||
modifyRequestHeader: function (xhr) {
|
||||
if (value.headers) {
|
||||
for (const [key, val] of Object.entries(value.headers)) {
|
||||
xhr.setRequestHeader(key, val);
|
||||
}
|
||||
}
|
||||
|
||||
return xhr;
|
||||
}
|
||||
};
|
||||
}, true);
|
||||
|
||||
// Player event handlers
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PLAYING, () => { sendPlaybackUpdate(1); playerCtrlStateUpdate(PlayerControlEvent.Play); });
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PAUSED, () => { sendPlaybackUpdate(2); playerCtrlStateUpdate(PlayerControlEvent.Pause); });
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ENDED, () => { sendPlaybackUpdate(0) });
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_TIME_UPDATED, () => {
|
||||
playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate);
|
||||
|
||||
if (Math.abs(dashPlayer.time() - playerPrevTime) >= playbackUpdateInterval) {
|
||||
sendPlaybackUpdate(dashPlayer.isPaused() ? 2 : 1);
|
||||
playerPrevTime = dashPlayer.time();
|
||||
}
|
||||
});
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_RATE_CHANGED, () => { sendPlaybackUpdate(dashPlayer.isPaused() ? 2 : 1) });
|
||||
|
||||
// Buffering UI update when paused
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PROGRESS, () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); });
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_VOLUME_CHANGED, () => {
|
||||
const updateVolume = dashPlayer.isMuted() ? 0 : dashPlayer.getVolume();
|
||||
playerCtrlStateUpdate(PlayerControlEvent.VolumeChange);
|
||||
window.electronAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume });
|
||||
});
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.ERROR, (data) => { window.electronAPI.sendPlaybackError({
|
||||
message: `DashJS ERROR: ${JSON.stringify(data)}`
|
||||
})});
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ERROR, (data) => { window.electronAPI.sendPlaybackError({
|
||||
message: `DashJS PLAYBACK_ERROR: ${JSON.stringify(data)}`
|
||||
})});
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.STREAM_INITIALIZED, () => { onPlayerLoad(value, currentPlaybackRate, currentVolume); });
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.CUE_ENTER, (e: any) => {
|
||||
const subtitle = document.createElement("p")
|
||||
subtitle.setAttribute("id", "subtitle-" + e.cueID)
|
||||
|
||||
subtitle.textContent = e.text;
|
||||
videoCaptions.appendChild(subtitle);
|
||||
});
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.CUE_EXIT, (e: any) => {
|
||||
document.getElementById("subtitle-" + e.cueID)?.remove();
|
||||
});
|
||||
|
||||
dashPlayer.updateSettings({
|
||||
// debug: {
|
||||
// logLevel: dashjs.LogLevel.LOG_LEVEL_INFO
|
||||
// },
|
||||
streaming: {
|
||||
text: {
|
||||
dispatchForManualRendering: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (value.content) {
|
||||
dashPlayer.initialize(videoElement, `data:${value.container};base64,` + window.btoa(value.content), true, value.time);
|
||||
// dashPlayer.initialize(videoElement, "https://dash.akamaized.net/akamai/test/caption_test/ElephantsDream/elephants_dream_480p_heaac5_1_https.mpd", true);
|
||||
} else {
|
||||
dashPlayer.initialize(videoElement, value.url, true, value.time);
|
||||
}
|
||||
|
||||
} else if ((value.container === 'application/vnd.apple.mpegurl' || value.container === 'application/x-mpegURL') && !videoElement.canPlayType(value.container)) {
|
||||
console.log("Loading hls player");
|
||||
|
||||
const config = {
|
||||
xhrSetup: function (xhr: XMLHttpRequest) {
|
||||
if (value.headers) {
|
||||
for (const [key, val] of Object.entries(value.headers)) {
|
||||
xhr.setRequestHeader(key, val);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const hlsPlayer = new Hls(config);
|
||||
|
||||
hlsPlayer.on(Hls.Events.ERROR, (eventName, data) => {
|
||||
window.electronAPI.sendPlaybackError({
|
||||
message: `HLS player error: ${JSON.stringify(data)}`
|
||||
});
|
||||
});
|
||||
|
||||
hlsPlayer.on(Hls.Events.LEVEL_LOADED, (eventName, level: LevelLoadedData) => {
|
||||
isLive = level.details.live;
|
||||
isLivePosition = isLive ? true : false;
|
||||
});
|
||||
|
||||
player = new Player(PlayerType.Hls, videoElement, hlsPlayer);
|
||||
|
||||
// value.url = "https://devstreaming-cdn.apple.com/videos/streaming/examples/adv_dv_atmos/main.m3u8?ref=developerinsider.co";
|
||||
hlsPlayer.loadSource(value.url);
|
||||
hlsPlayer.attachMedia(videoElement);
|
||||
// hlsPlayer.subtitleDisplay = true;
|
||||
|
||||
} else {
|
||||
console.log("Loading html player");
|
||||
player = new Player(PlayerType.Html, videoElement);
|
||||
|
||||
videoElement.src = value.url;
|
||||
videoElement.load();
|
||||
}
|
||||
|
||||
// Player event handlers
|
||||
if (player.playerType === PlayerType.Hls || player.playerType === PlayerType.Html) {
|
||||
videoElement.onplay = () => { sendPlaybackUpdate(1); playerCtrlStateUpdate(PlayerControlEvent.Play); };
|
||||
videoElement.onpause = () => { sendPlaybackUpdate(2); playerCtrlStateUpdate(PlayerControlEvent.Pause); };
|
||||
videoElement.onended = () => { sendPlaybackUpdate(0) };
|
||||
videoElement.ontimeupdate = () => {
|
||||
playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate);
|
||||
|
||||
if (Math.abs(videoElement.currentTime - playerPrevTime) >= playbackUpdateInterval) {
|
||||
sendPlaybackUpdate(videoElement.paused ? 2 : 1);
|
||||
playerPrevTime = videoElement.currentTime;
|
||||
}
|
||||
};
|
||||
// Buffering UI update when paused
|
||||
videoElement.onprogress = () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); };
|
||||
videoElement.onratechange = () => { sendPlaybackUpdate(videoElement.paused ? 2 : 1) };
|
||||
videoElement.onvolumechange = () => {
|
||||
const updateVolume = videoElement.muted ? 0 : videoElement.volume;
|
||||
playerCtrlStateUpdate(PlayerControlEvent.VolumeChange);
|
||||
window.electronAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume });
|
||||
};
|
||||
|
||||
videoElement.onerror = (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) => {
|
||||
console.error("Player error", {source, lineno, colno, error});
|
||||
};
|
||||
|
||||
videoElement.onloadedmetadata = () => { onPlayerLoad(value, currentPlaybackRate, currentVolume); };
|
||||
}
|
||||
}
|
||||
|
||||
// Sender generated event handlers
|
||||
window.electronAPI.onPause(() => { player.pause(); });
|
||||
window.electronAPI.onResume(() => { player.play(); });
|
||||
window.electronAPI.onSeek((_event, value: SeekMessage) => { player.setCurrentTime(value.time); });
|
||||
window.electronAPI.onSetVolume((_event, value: SetVolumeMessage) => { volumeChangeHandler(value.volume); });
|
||||
window.electronAPI.onSetSpeed((_event, value: SetSpeedMessage) => { player.setPlaybackRate(value.speed); playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate); });
|
||||
});
|
||||
|
||||
let scrubbing = false;
|
||||
let volumeChanging = false;
|
||||
|
||||
enum PlayerControlEvent {
|
||||
Load,
|
||||
Pause,
|
||||
Play,
|
||||
VolumeChange,
|
||||
TimeUpdate,
|
||||
UiFadeOut,
|
||||
UiFadeIn,
|
||||
SetCaptions,
|
||||
ToggleSpeedMenu,
|
||||
SetPlaybackRate,
|
||||
ToggleFullscreen,
|
||||
ExitFullscreen,
|
||||
}
|
||||
|
||||
// UI update handlers
|
||||
function playerCtrlStateUpdate(event: PlayerControlEvent) {
|
||||
export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent) {
|
||||
switch (event) {
|
||||
case PlayerControlEvent.Load: {
|
||||
playerCtrlProgressBarBuffer.setAttribute("style", "width: 0px");
|
||||
playerCtrlProgressBarProgress.setAttribute("style", "width: 0px");
|
||||
playerCtrlProgressBarHandle.setAttribute("style", `left: ${playerCtrlProgressBar.offsetLeft}px`);
|
||||
|
||||
const volume = Math.round(player.getVolume() * playerCtrlVolumeBar.offsetWidth);
|
||||
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
|
||||
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume + 8}px`);
|
||||
|
||||
if (isLive) {
|
||||
playerCtrlLiveBadge.setAttribute("style", "display: block");
|
||||
playerCtrlPosition.setAttribute("style", "display: none");
|
||||
playerCtrlDuration.setAttribute("style", "display: none");
|
||||
}
|
||||
else {
|
||||
playerCtrlLiveBadge.setAttribute("style", "display: none");
|
||||
playerCtrlPosition.setAttribute("style", "display: block");
|
||||
playerCtrlDuration.setAttribute("style", "display: block");
|
||||
playerCtrlPosition.textContent = formatDuration(player.getCurrentTime());
|
||||
playerCtrlDuration.innerHTML = `/  ${formatDuration(player.getDuration())}`;
|
||||
}
|
||||
|
||||
if (player.isCaptionsSupported()) {
|
||||
playerCtrlCaptions.setAttribute("style", "display: block");
|
||||
videoCaptions.setAttribute("style", "display: block");
|
||||
}
|
||||
else {
|
||||
playerCtrlCaptions.setAttribute("style", "display: none");
|
||||
videoCaptions.setAttribute("style", "display: none");
|
||||
player.enableCaptions(false);
|
||||
}
|
||||
playerCtrlStateUpdate(PlayerControlEvent.SetCaptions);
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.Pause:
|
||||
playerCtrlAction.setAttribute("class", "play");
|
||||
stopUiHideTimer();
|
||||
break;
|
||||
|
||||
case PlayerControlEvent.Play:
|
||||
playerCtrlAction.setAttribute("class", "pause");
|
||||
startUiHideTimer();
|
||||
break;
|
||||
|
||||
case PlayerControlEvent.VolumeChange: {
|
||||
// console.log(`VolumeChange: isMute ${player.isMuted()}, volume: ${player.getVolume()}`);
|
||||
const volume = Math.round(player.getVolume() * playerCtrlVolumeBar.offsetWidth);
|
||||
|
||||
if (player.isMuted()) {
|
||||
playerCtrlVolume.setAttribute("class", "mute");
|
||||
playerCtrlVolumeBarProgress.setAttribute("style", `width: 0px`);
|
||||
playerCtrlVolumeBarHandle.setAttribute("style", `left: 0px`);
|
||||
}
|
||||
else if (player.getVolume() >= 0.5) {
|
||||
playerCtrlVolume.setAttribute("class", "volume_high");
|
||||
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
|
||||
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume}px`);
|
||||
} else {
|
||||
playerCtrlVolume.setAttribute("class", "volume_low");
|
||||
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
|
||||
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume}px`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.TimeUpdate: {
|
||||
// console.log(`TimeUpdate: Position: ${player.getCurrentTime()}, Duration: ${player.getDuration()}`);
|
||||
|
||||
if (isLive) {
|
||||
if (isLivePosition && player.getDuration() - player.getCurrentTime() > livePositionWindow) {
|
||||
isLivePosition = false;
|
||||
playerCtrlLiveBadge.setAttribute("style", `background-color: #595959`);
|
||||
}
|
||||
else if (!isLivePosition && player.getDuration() - player.getCurrentTime() <= livePositionWindow) {
|
||||
isLivePosition = true;
|
||||
playerCtrlLiveBadge.setAttribute("style", `background-color: red`);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLivePosition) {
|
||||
playerCtrlProgressBarProgress.setAttribute("style", `width: ${playerCtrlProgressBar.offsetWidth}px`);
|
||||
playerCtrlProgressBarHandle.setAttribute("style", `left: ${playerCtrlProgressBar.offsetWidth + playerCtrlProgressBar.offsetLeft}px`);
|
||||
}
|
||||
else {
|
||||
const buffer = Math.round((player.getBufferLength() / player.getDuration()) * playerCtrlProgressBar.offsetWidth);
|
||||
const progress = Math.round((player.getCurrentTime() / player.getDuration()) * playerCtrlProgressBar.offsetWidth);
|
||||
const handle = progress + playerCtrlProgressBar.offsetLeft;
|
||||
|
||||
playerCtrlProgressBarBuffer.setAttribute("style", `width: ${buffer}px`);
|
||||
playerCtrlProgressBarProgress.setAttribute("style", `width: ${progress}px`);
|
||||
playerCtrlProgressBarHandle.setAttribute("style", `left: ${handle}px`);
|
||||
|
||||
playerCtrlPosition.textContent = formatDuration(player.getCurrentTime());
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.UiFadeOut:
|
||||
document.body.style.cursor = "none";
|
||||
playerControls.setAttribute("style", "opacity: 0");
|
||||
|
||||
if (player.isCaptionsEnabled()) {
|
||||
videoCaptions.setAttribute("style", "display: block; bottom: 75px;");
|
||||
} else {
|
||||
videoCaptions.setAttribute("style", "display: none; bottom: 75px;");
|
||||
}
|
||||
|
||||
|
||||
break;
|
||||
|
||||
case PlayerControlEvent.UiFadeIn:
|
||||
document.body.style.cursor = "default";
|
||||
playerControls.setAttribute("style", "opacity: 1");
|
||||
|
||||
if (player.isCaptionsEnabled()) {
|
||||
videoCaptions.setAttribute("style", "display: block; bottom: 160px;");
|
||||
} else {
|
||||
videoCaptions.setAttribute("style", "display: none; bottom: 160px;");
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case PlayerControlEvent.SetCaptions:
|
||||
if (player.isCaptionsEnabled()) {
|
||||
playerCtrlCaptions.setAttribute("class", "captions_on");
|
||||
videoCaptions.setAttribute("style", "display: block");
|
||||
} else {
|
||||
playerCtrlCaptions.setAttribute("class", "captions_off");
|
||||
videoCaptions.setAttribute("style", "display: none");
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case PlayerControlEvent.ToggleSpeedMenu: {
|
||||
if (playerCtrlSpeedMenuShown) {
|
||||
playerCtrlSpeedMenu.setAttribute("style", "display: none");
|
||||
} else {
|
||||
playerCtrlSpeedMenu.setAttribute("style", "display: block");
|
||||
}
|
||||
|
||||
playerCtrlSpeedMenuShown = !playerCtrlSpeedMenuShown;
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.SetPlaybackRate: {
|
||||
const rate = player.getPlaybackRate().toFixed(2);
|
||||
const entryElement = document.getElementById(`speedMenuEntry_${rate}_enabled`);
|
||||
|
||||
playbackRates.forEach(r => {
|
||||
const entry = document.getElementById(`speedMenuEntry_${r}_enabled`);
|
||||
entry.setAttribute("style", "opacity: 0");
|
||||
});
|
||||
|
||||
// Ignore updating GUI for custom rates
|
||||
if (entryElement !== null) {
|
||||
entryElement.setAttribute("style", "opacity: 1");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.ToggleFullscreen: {
|
||||
window.electronAPI.toggleFullScreen();
|
||||
|
||||
|
@ -502,199 +30,8 @@ function playerCtrlStateUpdate(event: PlayerControlEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
function scrubbingMouseUIHandler(e: MouseEvent) {
|
||||
const progressBarOffset = e.offsetX - 8;
|
||||
const progressBarWidth = PlayerCtrlProgressBarInteractiveArea.offsetWidth - 16;
|
||||
let time = isLive ? Math.round((1 - (progressBarOffset / progressBarWidth)) * player.getDuration()) : Math.round((progressBarOffset / progressBarWidth) * player.getDuration());
|
||||
time = Math.min(player.getDuration(), Math.max(0.0, time));
|
||||
|
||||
if (scrubbing && isLive && e.buttons === 1) {
|
||||
isLivePosition = false;
|
||||
playerCtrlLiveBadge.setAttribute("style", `background-color: #595959`);
|
||||
}
|
||||
|
||||
const livePrefix = isLive && Math.floor(time) !== 0 ? "-" : "";
|
||||
playerCtrlProgressBarPosition.textContent = isLive ? `${livePrefix}${formatDuration(time)}` : formatDuration(time);
|
||||
|
||||
let offset = e.offsetX - (playerCtrlProgressBarPosition.offsetWidth / 2);
|
||||
offset = Math.min(PlayerCtrlProgressBarInteractiveArea.offsetWidth - (playerCtrlProgressBarPosition.offsetWidth / 1), Math.max(8, offset));
|
||||
playerCtrlProgressBarPosition.setAttribute("style", `display: block; left: ${offset}px`);
|
||||
}
|
||||
|
||||
// Receiver generated event handlers
|
||||
playerCtrlAction.onclick = () => {
|
||||
if (player.isPaused()) {
|
||||
player.play();
|
||||
} else {
|
||||
player.pause();
|
||||
}
|
||||
};
|
||||
|
||||
playerCtrlVolume.onclick = () => { player.setMute(!player.isMuted()); };
|
||||
|
||||
PlayerCtrlProgressBarInteractiveArea.onmousedown = (e: MouseEvent) => { scrubbing = true; scrubbingMouseHandler(e) };
|
||||
PlayerCtrlProgressBarInteractiveArea.onmouseup = () => { scrubbing = false; };
|
||||
PlayerCtrlProgressBarInteractiveArea.onmouseenter = (e: MouseEvent) => {
|
||||
if (e.buttons === 0) {
|
||||
volumeChanging = false;
|
||||
}
|
||||
|
||||
scrubbingMouseUIHandler(e);
|
||||
};
|
||||
PlayerCtrlProgressBarInteractiveArea.onmouseleave = () => { playerCtrlProgressBarPosition.setAttribute("style", "display: none"); };
|
||||
PlayerCtrlProgressBarInteractiveArea.onmousemove = (e: MouseEvent) => { scrubbingMouseHandler(e) };
|
||||
|
||||
function scrubbingMouseHandler(e: MouseEvent) {
|
||||
const progressBarOffset = e.offsetX - 8;
|
||||
const progressBarWidth = PlayerCtrlProgressBarInteractiveArea.offsetWidth - 16;
|
||||
let time = Math.round((progressBarOffset / progressBarWidth) * player.getDuration());
|
||||
time = Math.min(player.getDuration(), Math.max(0.0, time));
|
||||
|
||||
if (scrubbing && e.buttons === 1) {
|
||||
player.setCurrentTime(time);
|
||||
}
|
||||
|
||||
scrubbingMouseUIHandler(e);
|
||||
}
|
||||
|
||||
playerCtrlVolumeBarInteractiveArea.onmousedown = (e: MouseEvent) => { volumeChanging = true; volumeChangeMouseHandler(e) };
|
||||
playerCtrlVolumeBarInteractiveArea.onmouseup = () => { volumeChanging = false; };
|
||||
playerCtrlVolumeBarInteractiveArea.onmouseenter = (e: MouseEvent) => {
|
||||
if (e.buttons === 0) {
|
||||
scrubbing = false;
|
||||
}
|
||||
};
|
||||
playerCtrlVolumeBarInteractiveArea.onmousemove = (e: MouseEvent) => { volumeChangeMouseHandler(e) };
|
||||
playerCtrlVolumeBarInteractiveArea.onwheel = (e: WheelEvent) => {
|
||||
const delta = -e.deltaY;
|
||||
|
||||
if (delta > 0 ) {
|
||||
volumeChangeHandler(Math.min(player.getVolume() + volumeIncrement, 1));
|
||||
} else if (delta < 0) {
|
||||
volumeChangeHandler(Math.max(player.getVolume() - volumeIncrement, 0));
|
||||
}
|
||||
};
|
||||
|
||||
function volumeChangeMouseHandler(e: MouseEvent) {
|
||||
if (volumeChanging && e.buttons === 1) {
|
||||
const volumeBarOffsetX = e.offsetX - 8;
|
||||
const volumeBarWidth = playerCtrlVolumeBarInteractiveArea.offsetWidth - 16;
|
||||
const volume = volumeBarOffsetX / volumeBarWidth;
|
||||
volumeChangeHandler(volume);
|
||||
}
|
||||
}
|
||||
|
||||
function volumeChangeHandler(volume: number) {
|
||||
if (!player.isMuted() && volume <= 0) {
|
||||
player.setMute(true);
|
||||
}
|
||||
else if (player.isMuted() && volume > 0) {
|
||||
player.setMute(false);
|
||||
}
|
||||
|
||||
player.setVolume(volume);
|
||||
}
|
||||
|
||||
playerCtrlLiveBadge.onclick = () => { setLivePosition(); };
|
||||
|
||||
function setLivePosition() {
|
||||
if (!isLivePosition) {
|
||||
isLivePosition = true;
|
||||
|
||||
player.setCurrentTime(player.getDuration() - livePositionDelta);
|
||||
playerCtrlLiveBadge.setAttribute("style", `background-color: red`);
|
||||
|
||||
if (player.isPaused()) {
|
||||
player.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playerCtrlCaptions.onclick = () => { player.enableCaptions(!player.isCaptionsEnabled()); playerCtrlStateUpdate(PlayerControlEvent.SetCaptions); };
|
||||
playerCtrlSpeed.onclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu); };
|
||||
playerCtrlFullscreen.onclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); };
|
||||
|
||||
playbackRates.forEach(r => {
|
||||
const entry = document.getElementById(`speedMenuEntry_${r}`);
|
||||
entry.onclick = () => {
|
||||
player.setPlaybackRate(parseFloat(r));
|
||||
playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate);
|
||||
playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu);
|
||||
};
|
||||
});
|
||||
|
||||
videoElement.onclick = () => {
|
||||
if (!playerCtrlSpeedMenuShown) {
|
||||
if (player.isPaused()) {
|
||||
player.play();
|
||||
} else {
|
||||
player.pause();
|
||||
}
|
||||
}
|
||||
};
|
||||
videoElement.ondblclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); };
|
||||
|
||||
// Component hiding
|
||||
let uiHideTimer = null;
|
||||
let uiVisible = true;
|
||||
|
||||
function startUiHideTimer() {
|
||||
if (uiHideTimer === null) {
|
||||
uiHideTimer = window.setTimeout(() => {
|
||||
uiHideTimer = null;
|
||||
uiVisible = false;
|
||||
playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function stopUiHideTimer() {
|
||||
if (uiHideTimer) {
|
||||
window.clearTimeout(uiHideTimer);
|
||||
uiHideTimer = null;
|
||||
}
|
||||
|
||||
if (!uiVisible) {
|
||||
uiVisible = true;
|
||||
playerCtrlStateUpdate(PlayerControlEvent.UiFadeIn);
|
||||
}
|
||||
}
|
||||
|
||||
document.onmouseout = () => {
|
||||
if (uiHideTimer) {
|
||||
window.clearTimeout(uiHideTimer);
|
||||
uiHideTimer = null;
|
||||
}
|
||||
|
||||
uiVisible = false;
|
||||
playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut);
|
||||
}
|
||||
|
||||
document.onmousemove = () => {
|
||||
stopUiHideTimer();
|
||||
|
||||
if (player && !player.isPaused()) {
|
||||
startUiHideTimer();
|
||||
}
|
||||
};
|
||||
|
||||
window.onresize = () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); };
|
||||
|
||||
// Listener for hiding speed menu when clicking outside element
|
||||
document.addEventListener('click', (event: MouseEvent) => {
|
||||
const node = event.target as Node;
|
||||
if (playerCtrlSpeedMenuShown && !playerCtrlSpeed.contains(node) && !playerCtrlSpeedMenu.contains(node)){
|
||||
playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu);
|
||||
}
|
||||
});
|
||||
|
||||
// Add the keydown event listener to the document
|
||||
const skipInterval = 10;
|
||||
const volumeIncrement = 0.1;
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
// console.log("KeyDown", event);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function targetKeyDownEventListener(event: any) {
|
||||
switch (event.code) {
|
||||
case 'KeyF':
|
||||
case 'F11':
|
||||
|
@ -705,53 +42,7 @@ document.addEventListener('keydown', (event) => {
|
|||
playerCtrlStateUpdate(PlayerControlEvent.ExitFullscreen);
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
// Skip back
|
||||
player.setCurrentTime(Math.max(player.getCurrentTime() - skipInterval, 0));
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
// Skip forward
|
||||
if (!isLivePosition) {
|
||||
player.setCurrentTime(Math.min(player.getCurrentTime() + skipInterval, player.getDuration()));
|
||||
}
|
||||
event.preventDefault();
|
||||
break;
|
||||
case "Home":
|
||||
player.setCurrentTime(0);
|
||||
event.preventDefault();
|
||||
break;
|
||||
case "End":
|
||||
if (isLive) {
|
||||
setLivePosition();
|
||||
}
|
||||
else {
|
||||
player.setCurrentTime(player.getDuration());
|
||||
}
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'KeyK':
|
||||
case 'Space':
|
||||
case 'Enter':
|
||||
// Pause/Continue
|
||||
if (player.isPaused()) {
|
||||
player.play();
|
||||
} else {
|
||||
player.pause();
|
||||
}
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'KeyM':
|
||||
// Mute toggle
|
||||
player.setMute(!player.isMuted());
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
// Volume up
|
||||
volumeChangeHandler(Math.min(player.getVolume() + volumeIncrement, 1));
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
// Volume down
|
||||
volumeChangeHandler(Math.max(player.getVolume() - volumeIncrement, 0));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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" ]
|
||||
}
|
|
@ -1,8 +1,16 @@
|
|||
const webpack = require('webpack');
|
||||
const path = require('path');
|
||||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||
// const buildMode = 'production';
|
||||
const buildMode = 'development';
|
||||
|
||||
const TARGET = 'electron';
|
||||
// const TARGET = 'webOS';
|
||||
// const TARGET = 'tizenOS';
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
mode: 'development',
|
||||
mode: buildMode,
|
||||
entry: './src/App.ts',
|
||||
target: 'electron-main',
|
||||
module: {
|
||||
|
@ -15,15 +23,43 @@ module.exports = [
|
|||
],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'src': path.resolve(__dirname, 'src'),
|
||||
'modules': path.resolve(__dirname, 'node_modules'),
|
||||
'common': path.resolve(__dirname, '../common/web'),
|
||||
},
|
||||
extensions: ['.tsx', '.ts', '.js'],
|
||||
},
|
||||
output: {
|
||||
filename: 'bundle.js',
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
},
|
||||
plugins: [
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
// Common assets
|
||||
{
|
||||
from: '../common/assets/**',
|
||||
to: './[path][name][ext]',
|
||||
context: path.resolve(__dirname, '..', 'common'),
|
||||
globOptions: { ignore: ['**/*.txt'] }
|
||||
},
|
||||
// Target assets
|
||||
{
|
||||
from: '**',
|
||||
to: './assets/[path][name][ext]',
|
||||
context: path.resolve(__dirname, 'assets'),
|
||||
globOptions: { ignore: [] }
|
||||
}
|
||||
],
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
TARGET: JSON.stringify(TARGET)
|
||||
})
|
||||
]
|
||||
},
|
||||
{
|
||||
mode: 'development',
|
||||
mode: buildMode,
|
||||
entry: {
|
||||
preload: './src/main/Preload.ts',
|
||||
renderer: './src/main/Renderer.ts',
|
||||
|
@ -39,15 +75,38 @@ module.exports = [
|
|||
],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'src': path.resolve(__dirname, 'src'),
|
||||
'modules': path.resolve(__dirname, 'node_modules'),
|
||||
'common': path.resolve(__dirname, '../common/web'),
|
||||
},
|
||||
extensions: ['.tsx', '.ts', '.js'],
|
||||
},
|
||||
output: {
|
||||
filename: '[name].js',
|
||||
path: path.resolve(__dirname, 'dist/main'),
|
||||
},
|
||||
plugins: [
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: '../common/web/main/common.css',
|
||||
to: '[name][ext]',
|
||||
},
|
||||
{
|
||||
from: './src/main/*',
|
||||
to: '[name][ext]',
|
||||
globOptions: { ignore: ['**/*.ts'] }
|
||||
}
|
||||
],
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
TARGET: JSON.stringify(TARGET)
|
||||
})
|
||||
]
|
||||
},
|
||||
{
|
||||
mode: 'development',
|
||||
mode: buildMode,
|
||||
entry: {
|
||||
preload: './src/player/Preload.ts',
|
||||
renderer: './src/player/Renderer.ts',
|
||||
|
@ -63,11 +122,34 @@ module.exports = [
|
|||
],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'src': path.resolve(__dirname, 'src'),
|
||||
'modules': path.resolve(__dirname, 'node_modules'),
|
||||
'common': path.resolve(__dirname, '../common/web'),
|
||||
},
|
||||
extensions: ['.tsx', '.ts', '.js'],
|
||||
},
|
||||
output: {
|
||||
filename: '[name].js',
|
||||
path: path.resolve(__dirname, 'dist/player'),
|
||||
},
|
||||
plugins: [
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: '../common/web/player/common.css',
|
||||
to: '[name][ext]',
|
||||
},
|
||||
{
|
||||
from: './src/player/*',
|
||||
to: '[name][ext]',
|
||||
globOptions: { ignore: ['**/*.ts'] }
|
||||
}
|
||||
],
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
TARGET: JSON.stringify(TARGET)
|
||||
})
|
||||
]
|
||||
}
|
||||
];
|
133
receivers/webos/fcast-receiver-service/.gitignore
vendored
Normal 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
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"api_level": "0"
|
||||
}
|
95
receivers/webos/fcast-receiver-service/README.md
Normal file
|
@ -0,0 +1,95 @@
|
|||
# What is FCast?
|
||||
|
||||
FCast is a protocol designed for wireless streaming of audio and video content between devices. Unlike alternative protocols like Chromecast and AirPlay, FCast is an open source protocol that allows for custom receiver implementations, enabling third-party developers to create their own receiver devices or integrate the FCast protocol into their own apps.
|
||||
|
||||
# Why do I need a receiver?
|
||||
|
||||
The FCast receiver is a working receiver implementation compatible with Linux, Windows and MacOS that supports various stream types such as DASH, HLS and mp4.
|
||||
|
||||

|
||||
|
||||
# Protocol specification
|
||||
|
||||
The protocol specification can be found here https://gitlab.futo.org/videostreaming/fcast/-/wikis/home
|
||||
|
||||
# Receiver application
|
||||
|
||||
1. Download the latest build for your platform from https://fcast.org/#downloads or build it yourself by following the build instructions.
|
||||
2. Unzip the archive at your desired location.
|
||||
3. Run the FCast receiver.
|
||||
- **MacOS:** Run the `FCast Receiver` application
|
||||
- **Linux:** Run the `fcast-receiver` application
|
||||
- **Windows:** Run the `fcast-receiver.exe` application
|
||||
4. The application will open the main window where you can setup a connection to your sender device.
|
||||
|
||||

|
||||
|
||||
The application will continue to run in the system tray when you close the player or main window. You can exit the application or access other menu options from the tray icon.
|
||||
|
||||
There are also command line flags to customize application behavior, some of which include:
|
||||
* `--no-main-window`: Hide the main window on start
|
||||
* `--fullscreen`: Start the main window in fullscreen
|
||||
|
||||
Use the `--help` flag to see full list of available flags.
|
||||
|
||||
# Connecting to the FCast receiver with the video streaming application
|
||||
|
||||
## Automatic discovery
|
||||
|
||||
1. Open the video streaming application.
|
||||
2. Open the FCast receiver or restart it.
|
||||
3. The receiver should now be visible in the casting dialog under "Discovered Devices".
|
||||
4. If this failed, try manually connecting it. Automatic discovery does not work on all network types.
|
||||
5. Click start to connect to the device.
|
||||
6. Start watching content.
|
||||
|
||||
## Manual
|
||||
|
||||
1. Open the FCast receiver.
|
||||
2. Find the IP of the device running the receiver.
|
||||
3. Open the video streaming application.
|
||||
4. Open the casting dialog.
|
||||
5. Click add to manually add a device.
|
||||
6. Select the FCast protocol, enter a descriptive name, the IP you found and port 46899.
|
||||
7. Click start to connect to the device.
|
||||
8. Start watching content.
|
||||
|
||||

|
||||
|
||||
# How to build
|
||||
|
||||
## Preparing for build
|
||||
|
||||
A docker file is provided to setup your build environment. From the root of the repository:
|
||||
* Build: `docker build -t fcast/receiver-electron-dev:latest receivers/electron/`
|
||||
* Run: `docker run --rm -it -w /app/receivers/electron --entrypoint='bash' -v .:/app fcast/receiver-electron-dev:latest`
|
||||
|
||||
You can then run the following commands to finish setup.
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Run the following commands in the `/app/receivers/electron` directory.
|
||||
* Build: `npm run build`
|
||||
* Run: `npm run start`
|
||||
|
||||
## Packaging
|
||||
|
||||
Below are the following platforms currently used for packaging:
|
||||
* `npm run make -- --platform="darwin" --arch="arm64"`
|
||||
* `npm run make -- --platform="darwin" --arch="x64"`
|
||||
* `npm run make -- --platform="win32" --arch="x64"`
|
||||
* `npm run make -- --platform="linux" --arch="x64"`
|
||||
* `npm run make -- --platform="linux" --arch="arm64"`
|
||||
|
||||
Other platforms and architectures supported by [Electron Forge](https://www.electronforge.io/) might work, but are currently untested.
|
||||
|
||||
Packages that will be built from running the above `make` commands:
|
||||
* Windows: `.msi` and `.zip`
|
||||
* MacOS: `.dmg` and `.zip`
|
||||
* Linux: `.deb`, `.rpm` and `.zip`
|
||||
|
||||
Package artifacts will be located in `/app/receivers/electron/out/make`
|
11
receivers/webos/fcast-receiver-service/eslint.config.mjs
Normal 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,
|
||||
];
|
6
receivers/webos/fcast-receiver-service/jest.config.js
Normal 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"],
|
||||
};
|
6829
receivers/webos/fcast-receiver-service/package-lock.json
generated
Normal file
41
receivers/webos/fcast-receiver-service/package.json
Normal file
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"name": "com.futo.fcast.receiver.service",
|
||||
"version": "1.0.0",
|
||||
"description": "FCast network service",
|
||||
"author": "FUTO",
|
||||
"license": "MIT",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"build": "rm -rf dist/ && webpack --config ./webpack.config.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.10.0",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/mdns": "^0.0.38",
|
||||
"@types/node-forge": "^1.3.10",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/webos-service": "^0.4.6",
|
||||
"@types/webostvjs": "^1.2.6",
|
||||
"@types/ws": "^8.5.10",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"eslint": "^9.10.0",
|
||||
"globals": "^15.9.0",
|
||||
"jest": "^29.7.0",
|
||||
"mdns-js": "github:mdns-js/node-mdns-js",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.4.2",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript-eslint": "^8.4.0",
|
||||
"webpack": "^5.75.0",
|
||||
"webpack-cli": "^5.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"http": "^0.0.1-security",
|
||||
"log4js": "^6.9.1",
|
||||
"url": "^0.11.3",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.14.2"
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
+
|
9
receivers/webos/fcast-receiver-service/services.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"id": "com.futo.fcast.receiver.service",
|
||||
"description": "FCast network service",
|
||||
"services": [
|
||||
{
|
||||
"name": "com.futo.fcast.receiver.service"
|
||||
}
|
||||
]
|
||||
}
|
268
receivers/webos/fcast-receiver-service/src/Main.ts
Normal file
|
@ -0,0 +1,268 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// No node module for this package, only exists in webOS environment
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const Service = __non_webpack_require__('webos-service');
|
||||
// const Service = require('webos-service');
|
||||
|
||||
import { PlayMessage, PlaybackErrorMessage, PlaybackUpdateMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage, VolumeUpdateMessage } from 'common/Packets';
|
||||
import { DiscoveryService } from 'common/DiscoveryService';
|
||||
import { TcpListenerService } from 'common/TcpListenerService';
|
||||
import { WebSocketListenerService } from 'common/WebSocketListenerService';
|
||||
import { NetworkService } from 'common/NetworkService';
|
||||
import { Opcode } from 'common/FCastSession';
|
||||
import * as os from 'os';
|
||||
import * as log4js from "log4js";
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
export class Main {
|
||||
static tcpListenerService: TcpListenerService;
|
||||
static webSocketListenerService: WebSocketListenerService;
|
||||
static discoveryService: DiscoveryService;
|
||||
static logger: log4js.Logger;
|
||||
|
||||
static {
|
||||
try {
|
||||
log4js.configure({
|
||||
appenders: {
|
||||
console: { type: 'console' },
|
||||
},
|
||||
categories: {
|
||||
default: { appenders: ['console'], level: 'info' },
|
||||
},
|
||||
});
|
||||
Main.logger = log4js.getLogger();
|
||||
// Main.logger.info(`Starting application: ${app.name} | ${app.getAppPath()}`);
|
||||
// Main.logger.info(`Version: ${app.getVersion()}`);
|
||||
// Main.logger.info(`Commit: ${Updater.getCommit()}`);
|
||||
// Main.logger.info(`Release channel: ${Updater.releaseChannel} - ${Updater.getChannelVersion()}`);
|
||||
Main.logger.info(`OS: ${process.platform} ${process.arch}`);
|
||||
|
||||
const serviceId = 'com.futo.fcast.receiver.service';
|
||||
const service = new Service(serviceId);
|
||||
|
||||
|
||||
// let keepAlive;
|
||||
// service.activityManager.create("keepAlive", function(activity) {
|
||||
// keepAlive = activity;
|
||||
// });
|
||||
// // When you're done, complete the activity
|
||||
// service.activityManager.complete(keepAlive, function(activity) {
|
||||
// Main.logger.info("completed activity");
|
||||
// });
|
||||
|
||||
service.register("getDeviceInfo", (message: any) => {
|
||||
Main.logger.info("In getDeviceInfo callback");
|
||||
|
||||
message.respond({
|
||||
returnValue: true,
|
||||
value: { name: os.hostname(), addresses: NetworkService.getAllIPv4Addresses() }
|
||||
});
|
||||
});
|
||||
|
||||
Main.discoveryService = new DiscoveryService();
|
||||
Main.discoveryService.start();
|
||||
|
||||
Main.tcpListenerService = new TcpListenerService();
|
||||
Main.webSocketListenerService = new WebSocketListenerService();
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
let playData: PlayMessage = null;
|
||||
|
||||
let playClosureCb = null;
|
||||
const playCb = (message: any, playMessage: PlayMessage) => {
|
||||
playData = playMessage;
|
||||
message.respond({ returnValue: true, value: { playData: playData } });
|
||||
};
|
||||
|
||||
let pauseClosureCb: any = null;
|
||||
let resumeClosureCb: any = null;
|
||||
let stopClosureCb: any = null;
|
||||
const voidCb = (message: any) => { message.respond({ returnValue: true, value: {} }); };
|
||||
|
||||
let seekClosureCb = null;
|
||||
const seekCb = (message: any, seekMessage: SeekMessage) => { message.respond({ returnValue: true, value: seekMessage }); };
|
||||
|
||||
let setVolumeClosureCb = null;
|
||||
const setVolumeCb = (message: any, volumeMessage: SetVolumeMessage) => { message.respond({ returnValue: true, value: volumeMessage }); };
|
||||
|
||||
let setSpeedClosureCb = null;
|
||||
const setSpeedCb = (message: any, speedMessage: SetSpeedMessage) => { message.respond({ returnValue: true, value: speedMessage }); };
|
||||
|
||||
// Note: When logging the `message` object, do NOT use JSON.stringify, you can log messages directly. Seems to be a circular reference causing errors...
|
||||
// const playService = service.register("play", (message) => {
|
||||
service.register("play", (message: any) => {
|
||||
if (message.isSubscription) {
|
||||
playClosureCb = playCb.bind(this, message);
|
||||
emitter.on('play', playClosureCb);
|
||||
}
|
||||
|
||||
message.respond({ returnValue: true, value: { subscribed: true, playData: playData }});
|
||||
},
|
||||
(message: any) => {
|
||||
Main.logger.info('Canceled play service subscriber');
|
||||
emitter.off('play', playClosureCb);
|
||||
message.respond({ returnValue: true, value: message.payload });
|
||||
});
|
||||
|
||||
service.register("pause", (message: any) => {
|
||||
if (message.isSubscription) {
|
||||
pauseClosureCb = voidCb.bind(this, message);
|
||||
emitter.on('pause', pauseClosureCb);
|
||||
}
|
||||
|
||||
message.respond({ returnValue: true, value: { subscribed: true }});
|
||||
},
|
||||
(message: any) => {
|
||||
Main.logger.info('Canceled pause service subscriber');
|
||||
emitter.off('pause', pauseClosureCb);
|
||||
message.respond({ returnValue: true, value: message.payload });
|
||||
});
|
||||
|
||||
service.register("resume", (message: any) => {
|
||||
if (message.isSubscription) {
|
||||
resumeClosureCb = voidCb.bind(this, message);
|
||||
emitter.on('resume', resumeClosureCb);
|
||||
}
|
||||
|
||||
message.respond({ returnValue: true, value: { subscribed: true }});
|
||||
},
|
||||
(message: any) => {
|
||||
Main.logger.info('Canceled resume service subscriber');
|
||||
emitter.off('resume', resumeClosureCb);
|
||||
message.respond({ returnValue: true, value: message.payload });
|
||||
});
|
||||
|
||||
service.register("stop", (message: any) => {
|
||||
playData = null;
|
||||
|
||||
if (message.isSubscription) {
|
||||
stopClosureCb = voidCb.bind(this, message);
|
||||
emitter.on('stop', stopClosureCb);
|
||||
}
|
||||
|
||||
message.respond({ returnValue: true, value: { subscribed: true }});
|
||||
},
|
||||
(message: any) => {
|
||||
Main.logger.info('Canceled stop service subscriber');
|
||||
emitter.off('stop', stopClosureCb);
|
||||
message.respond({ returnValue: true, value: message.payload });
|
||||
});
|
||||
|
||||
service.register("seek", (message: any) => {
|
||||
if (message.isSubscription) {
|
||||
seekClosureCb = seekCb.bind(this, message);
|
||||
emitter.on('seek', seekClosureCb);
|
||||
}
|
||||
|
||||
message.respond({ returnValue: true, value: { subscribed: true }});
|
||||
},
|
||||
(message: any) => {
|
||||
Main.logger.info('Canceled seek service subscriber');
|
||||
emitter.off('seek', seekClosureCb);
|
||||
message.respond({ returnValue: true, value: message.payload });
|
||||
});
|
||||
|
||||
service.register("setvolume", (message: any) => {
|
||||
if (message.isSubscription) {
|
||||
setVolumeClosureCb = setVolumeCb.bind(this, message);
|
||||
emitter.on('setvolume', setVolumeClosureCb);
|
||||
}
|
||||
|
||||
message.respond({ returnValue: true, value: { subscribed: true }});
|
||||
},
|
||||
(message: any) => {
|
||||
Main.logger.info('Canceled setvolume service subscriber');
|
||||
emitter.off('setvolume', setVolumeClosureCb);
|
||||
message.respond({ returnValue: true, value: message.payload });
|
||||
});
|
||||
|
||||
service.register("setspeed", (message: any) => {
|
||||
if (message.isSubscription) {
|
||||
setSpeedClosureCb = setSpeedCb.bind(this, message);
|
||||
emitter.on('setspeed', setSpeedClosureCb);
|
||||
}
|
||||
|
||||
message.respond({ returnValue: true, value: { subscribed: true }});
|
||||
},
|
||||
(message: any) => {
|
||||
Main.logger.info('Canceled setspeed service subscriber');
|
||||
emitter.off('setspeed', setSpeedClosureCb);
|
||||
message.respond({ returnValue: true, value: message.payload });
|
||||
});
|
||||
|
||||
const listeners = [Main.tcpListenerService, Main.webSocketListenerService];
|
||||
listeners.forEach(l => {
|
||||
l.emitter.on("play", async (message) => {
|
||||
await NetworkService.proxyPlayIfRequired(message);
|
||||
emitter.emit('play', message);
|
||||
|
||||
service.call("luna://com.webos.applicationManager/launch", { playData: message }, (_response) => {
|
||||
console.log(`Relaunching FCast Receiver with args: ${JSON.stringify(message)}`);
|
||||
});
|
||||
// const appId = 'com.futo.fcast.receiver';
|
||||
// window.webOSDev.launch({
|
||||
// id: appId,
|
||||
// params: {
|
||||
// playData: message,
|
||||
// },
|
||||
// onSuccess: function () {
|
||||
// console.log(`Service: Launching application...`);
|
||||
// },
|
||||
// onFailure: function (message: any) {
|
||||
// console.error(`Service: launch ${JSON.stringify(message)}`);
|
||||
// },
|
||||
// });
|
||||
});
|
||||
l.emitter.on("pause", () => emitter.emit('pause'));
|
||||
l.emitter.on("resume", () => emitter.emit('resume'));
|
||||
l.emitter.on("stop", () => emitter.emit('stop'));
|
||||
l.emitter.on("seek", (message) => emitter.emit('seek', message));
|
||||
l.emitter.on("setvolume", (message) => emitter.emit('setvolume', message));
|
||||
l.emitter.on("setspeed", (message) => emitter.emit('setspeed', message));
|
||||
l.start();
|
||||
});
|
||||
|
||||
service.register("send_playback_error", (message: any) => {
|
||||
listeners.forEach(l => {
|
||||
const value: PlaybackErrorMessage = message.payload.error;
|
||||
l.send(Opcode.PlaybackError, value);
|
||||
});
|
||||
|
||||
message.respond({ returnValue: true, value: { success: true } });
|
||||
});
|
||||
|
||||
service.register("send_playback_update", (message: any) => {
|
||||
// Main.logger.info("In send_playback_update callback");
|
||||
|
||||
listeners.forEach(l => {
|
||||
const value: PlaybackUpdateMessage = message.payload.update;
|
||||
l.send(Opcode.PlaybackUpdate, value);
|
||||
});
|
||||
|
||||
message.respond({ returnValue: true, value: { success: true } });
|
||||
});
|
||||
|
||||
service.register("send_volume_update", (message: any) => {
|
||||
listeners.forEach(l => {
|
||||
const value: VolumeUpdateMessage = message.payload.update;
|
||||
l.send(Opcode.VolumeUpdate, value);
|
||||
});
|
||||
|
||||
message.respond({ returnValue: true, value: { success: true } });
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
Main.logger.error("Error initializing service:", err);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export function getComputerName() {
|
||||
return os.hostname();
|
||||
}
|
||||
|
||||
export async function errorHandler(err: NodeJS.ErrnoException) {
|
||||
Main.logger.error("Application error:", err);
|
||||
}
|
21
receivers/webos/fcast-receiver-service/tsconfig.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "node",
|
||||
"sourceMap": false,
|
||||
"emitDecoratorMetadata": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"removeComments": false,
|
||||
"noImplicitAny": false,
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"src/*": ["./src/*"],
|
||||
"modules/*": ["./node_modules/*"],
|
||||
"common/*": ["../../common/web/*"],
|
||||
}
|
||||
},
|
||||
"exclude": [ "node_modules", "test" ]
|
||||
}
|
55
receivers/webos/fcast-receiver-service/webpack.config.js
Normal file
|
@ -0,0 +1,55 @@
|
|||
const webpack = require('webpack');
|
||||
const path = require('path');
|
||||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||
|
||||
// Build issues:
|
||||
// * 'development' mode breaks running the service on WebOS hardware... Must use 'production'.
|
||||
// * Must use '--no-minify' when packaging since packaging would break otherwise...
|
||||
const buildMode = 'production';
|
||||
// const buildMode = 'development';
|
||||
|
||||
// const TARGET = 'electron';
|
||||
const TARGET = 'webOS';
|
||||
// const TARGET = 'tizenOS';
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
mode: buildMode,
|
||||
entry: {
|
||||
main: './src/Main.ts',
|
||||
},
|
||||
target: 'node',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
include: /src/,
|
||||
use: [{ loader: 'ts-loader' }]
|
||||
}
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'src': path.resolve(__dirname, 'src'),
|
||||
'modules': path.resolve(__dirname, 'node_modules'),
|
||||
'common': path.resolve(__dirname, '../../common/web'),
|
||||
},
|
||||
extensions: ['.tsx', '.ts', '.js'],
|
||||
},
|
||||
output: {
|
||||
filename: '[name].js',
|
||||
path: path.resolve(__dirname, 'dist/main'),
|
||||
},
|
||||
plugins: [
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{ from: 'package.json', to: '[name][ext]' },
|
||||
{ from: 'services.json', to: '[name][ext]' },
|
||||
],
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
TARGET: JSON.stringify(TARGET)
|
||||
})
|
||||
]
|
||||
}
|
||||
];
|
133
receivers/webos/fcast-receiver/.gitignore
vendored
Normal 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
|
3
receivers/webos/fcast-receiver/.webosstudio.config
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"api_level": "0"
|
||||
}
|
95
receivers/webos/fcast-receiver/README.md
Normal file
|
@ -0,0 +1,95 @@
|
|||
# What is FCast?
|
||||
|
||||
FCast is a protocol designed for wireless streaming of audio and video content between devices. Unlike alternative protocols like Chromecast and AirPlay, FCast is an open source protocol that allows for custom receiver implementations, enabling third-party developers to create their own receiver devices or integrate the FCast protocol into their own apps.
|
||||
|
||||
# Why do I need a receiver?
|
||||
|
||||
The FCast receiver is a working receiver implementation compatible with Linux, Windows and MacOS that supports various stream types such as DASH, HLS and mp4.
|
||||
|
||||

|
||||
|
||||
# Protocol specification
|
||||
|
||||
The protocol specification can be found here https://gitlab.futo.org/videostreaming/fcast/-/wikis/home
|
||||
|
||||
# Receiver application
|
||||
|
||||
1. Download the latest build for your platform from https://fcast.org/#downloads or build it yourself by following the build instructions.
|
||||
2. Unzip the archive at your desired location.
|
||||
3. Run the FCast receiver.
|
||||
- **MacOS:** Run the `FCast Receiver` application
|
||||
- **Linux:** Run the `fcast-receiver` application
|
||||
- **Windows:** Run the `fcast-receiver.exe` application
|
||||
4. The application will open the main window where you can setup a connection to your sender device.
|
||||
|
||||

|
||||
|
||||
The application will continue to run in the system tray when you close the player or main window. You can exit the application or access other menu options from the tray icon.
|
||||
|
||||
There are also command line flags to customize application behavior, some of which include:
|
||||
* `--no-main-window`: Hide the main window on start
|
||||
* `--fullscreen`: Start the main window in fullscreen
|
||||
|
||||
Use the `--help` flag to see full list of available flags.
|
||||
|
||||
# Connecting to the FCast receiver with the video streaming application
|
||||
|
||||
## Automatic discovery
|
||||
|
||||
1. Open the video streaming application.
|
||||
2. Open the FCast receiver or restart it.
|
||||
3. The receiver should now be visible in the casting dialog under "Discovered Devices".
|
||||
4. If this failed, try manually connecting it. Automatic discovery does not work on all network types.
|
||||
5. Click start to connect to the device.
|
||||
6. Start watching content.
|
||||
|
||||
## Manual
|
||||
|
||||
1. Open the FCast receiver.
|
||||
2. Find the IP of the device running the receiver.
|
||||
3. Open the video streaming application.
|
||||
4. Open the casting dialog.
|
||||
5. Click add to manually add a device.
|
||||
6. Select the FCast protocol, enter a descriptive name, the IP you found and port 46899.
|
||||
7. Click start to connect to the device.
|
||||
8. Start watching content.
|
||||
|
||||

|
||||
|
||||
# How to build
|
||||
|
||||
## Preparing for build
|
||||
|
||||
A docker file is provided to setup your build environment. From the root of the repository:
|
||||
* Build: `docker build -t fcast/receiver-electron-dev:latest receivers/electron/`
|
||||
* Run: `docker run --rm -it -w /app/receivers/electron --entrypoint='bash' -v .:/app fcast/receiver-electron-dev:latest`
|
||||
|
||||
You can then run the following commands to finish setup.
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Run the following commands in the `/app/receivers/electron` directory.
|
||||
* Build: `npm run build`
|
||||
* Run: `npm run start`
|
||||
|
||||
## Packaging
|
||||
|
||||
Below are the following platforms currently used for packaging:
|
||||
* `npm run make -- --platform="darwin" --arch="arm64"`
|
||||
* `npm run make -- --platform="darwin" --arch="x64"`
|
||||
* `npm run make -- --platform="win32" --arch="x64"`
|
||||
* `npm run make -- --platform="linux" --arch="x64"`
|
||||
* `npm run make -- --platform="linux" --arch="arm64"`
|
||||
|
||||
Other platforms and architectures supported by [Electron Forge](https://www.electronforge.io/) might work, but are currently untested.
|
||||
|
||||
Packages that will be built from running the above `make` commands:
|
||||
* Windows: `.msi` and `.zip`
|
||||
* MacOS: `.dmg` and `.zip`
|
||||
* Linux: `.deb`, `.rpm` and `.zip`
|
||||
|
||||
Package artifacts will be located in `/app/receivers/electron/out/make`
|
13
receivers/webos/fcast-receiver/appinfo.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"id": "com.futo.fcast.receiver",
|
||||
"version": "1.0.0",
|
||||
"vendor": "FUTO",
|
||||
"type": "web",
|
||||
"main": "main_window/index.html",
|
||||
"title": "FCast Receiver",
|
||||
"appDescription": "FCast Receiver",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"largeIcon": "assets/icons/largeIcon.png",
|
||||
"iconColor": "#0a62f5",
|
||||
"splashBackground": "assets/images/splash.png"
|
||||
}
|
BIN
receivers/webos/fcast-receiver/assets/icons/icon.png
Normal file
After Width: | Height: | Size: 8.9 KiB |
BIN
receivers/webos/fcast-receiver/assets/icons/largeIcon.png
Normal file
After Width: | Height: | Size: 9.2 KiB |