Merge branch 'michael/webos' into 'master'
Michael/webos See merge request videostreaming/fcast!10
4
.gitignore
vendored
|
@ -4,4 +4,6 @@
|
|||
.vscode
|
||||
|
||||
node_modules/
|
||||
.wrangler/
|
||||
.wrangler/
|
||||
|
||||
receivers/webos/*.ipk
|
||||
|
|
|
@ -2,6 +2,7 @@ stages:
|
|||
- buildDockerContainers
|
||||
- buildAndDeployAndroid
|
||||
- buildAndDeployElectron
|
||||
- buildWebOSReceiver
|
||||
|
||||
variables:
|
||||
ANDROID_VERSION_NAME:
|
||||
|
@ -13,4 +14,5 @@ variables:
|
|||
|
||||
include:
|
||||
- local: 'receivers/android/.gitlab-ci.yml'
|
||||
- local: 'receivers/electron/.gitlab-ci.yml'
|
||||
- local: 'receivers/electron/.gitlab-ci.yml'
|
||||
- local: 'receivers/webos/.gitlab-ci.yml'
|
||||
|
|
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"); }
|
4
receivers/common/assets/icons/app/checked.svg
Normal file
|
@ -0,0 +1,4 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="#019BE7"/>
|
||||
<path d="M8 12.6L10.5397 15L16 9" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 266 B |
4
receivers/common/assets/icons/app/error.svg
Normal file
|
@ -0,0 +1,4 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.44873 1.9375C7.13805 0.6875 8.86195 0.6875 9.55246 1.9375L15.7599 13.1875C15.9172 13.4725 16 13.7959 16 14.125C16 14.4541 15.9172 14.7774 15.7599 15.0625C15.6027 15.3475 15.3764 15.5842 15.104 15.7488C14.8316 15.9133 14.5226 16 14.2081 16H1.79314C1.47848 16.0002 1.16932 15.9137 0.896742 15.7492C0.624165 15.5848 0.397785 15.3481 0.240368 15.063C0.0829523 14.7779 5.04209e-05 14.4545 2.29922e-08 14.1253C-5.03749e-05 13.7961 0.0827525 13.4726 0.240081 13.1875L6.44873 1.9375Z" fill="#F97066"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.09511 5C8.31294 5 8.52184 5.08889 8.67587 5.24713C8.8299 5.40536 8.91643 5.61997 8.91643 5.84375V9.21875C8.91643 9.44253 8.8299 9.65714 8.67587 9.81537C8.52184 9.97361 8.31294 10.0625 8.09511 10.0625C7.87728 10.0625 7.66837 9.97361 7.51434 9.81537C7.36031 9.65714 7.27378 9.44253 7.27378 9.21875V5.84375C7.27378 5.61997 7.36031 5.40536 7.51434 5.24713C7.66837 5.08889 7.87728 5 8.09511 5ZM8.09511 14C8.38555 14 8.66409 13.8815 8.86946 13.6705C9.07483 13.4595 9.19021 13.1734 9.19021 12.875C9.19021 12.5766 9.07483 12.2905 8.86946 12.0795C8.66409 11.8685 8.38555 11.75 8.09511 11.75C7.80467 11.75 7.52612 11.8685 7.32075 12.0795C7.11538 12.2905 7 12.5766 7 12.875C7 13.1734 7.11538 13.4595 7.32075 13.6705C7.52612 13.8815 7.80467 14 8.09511 14Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.4 KiB |
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 |
3
receivers/common/assets/icons/app/info.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2.38608C17.525 2.38608 22 6.86108 22 12.3861C22 17.9111 17.525 22.3861 12 22.3861C6.475 22.3861 2 17.9111 2 12.3861C2 6.86108 6.475 2.38608 12 2.38608ZM13.25 7.38608C13.25 6.69858 12.6875 6.13608 12 6.13608C11.3125 6.13608 10.75 6.69858 10.75 7.38608C10.75 8.07358 11.3125 8.63608 12 8.63608C12.6875 8.63608 13.25 8.07358 13.25 7.38608ZM13.25 18.6361V11.1361H10.75V18.6361H13.25Z" fill="#019BE7"/>
|
||||
</svg>
|
After Width: | Height: | Size: 514 B |
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 { EventEmitter } from 'node:events';
|
||||
import { PlaybackErrorMessage, PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage, VersionMessage, VolumeUpdateMessage } from './Packets';
|
||||
import { WebSocket } from 'ws';
|
||||
import * as log4js from "modules/log4js";
|
||||
import { EventEmitter } from 'events';
|
||||
import { PlaybackErrorMessage, PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage, VersionMessage, VolumeUpdateMessage } from 'common/Packets';
|
||||
import { WebSocket } from 'modules/ws';
|
||||
const logger = log4js.getLogger();
|
||||
|
||||
enum SessionState {
|
||||
|
@ -61,7 +61,31 @@ export class FCastSession {
|
|||
|
||||
const size = 1 + data.length;
|
||||
const header = Buffer.alloc(4 + 1);
|
||||
header.writeUint32LE(size, 0);
|
||||
|
||||
// Web OS 22 and earlier node versions do not support `writeUint32LE`,
|
||||
// so manually checking endianness and writing as LE
|
||||
// @ts-ignore
|
||||
if (TARGET === 'webOS') {
|
||||
let uInt32 = new Uint32Array([0x11223344]);
|
||||
let uInt8 = new Uint8Array(uInt32.buffer);
|
||||
|
||||
if(uInt8[0] === 0x44) {
|
||||
// LE
|
||||
header[0] = size & 0xFF;
|
||||
header[1] = size & 0xFF00;
|
||||
header[2] = size & 0xFF0000;
|
||||
header[3] = size & 0xFF000000;
|
||||
} else if (uInt8[0] === 0x11) {
|
||||
// BE
|
||||
header[0] = size & 0xFF000000;
|
||||
header[1] = size & 0xFF0000;
|
||||
header[2] = size & 0xFF00;
|
||||
header[3] = size & 0xFF;
|
||||
}
|
||||
} else {
|
||||
header.writeUint32LE(size, 0);
|
||||
}
|
||||
|
||||
header[4] = opcode;
|
||||
|
||||
let packet: Buffer;
|
||||
|
@ -178,8 +202,15 @@ export class FCastSession {
|
|||
case Opcode.SetSpeed:
|
||||
this.emitter.emit("setspeed", JSON.parse(body) as SetSpeedMessage);
|
||||
break;
|
||||
case Opcode.Version:
|
||||
this.emitter.emit("version", JSON.parse(body) as VersionMessage);
|
||||
break;
|
||||
case Opcode.Ping:
|
||||
this.send(Opcode.Pong);
|
||||
this.emitter.emit("ping");
|
||||
break;
|
||||
case Opcode.Pong:
|
||||
this.emitter.emit("pong");
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
|
@ -204,5 +235,8 @@ export class FCastSession {
|
|||
this.emitter.on("seek", (body: SeekMessage) => { emitter.emit("seek", body) });
|
||||
this.emitter.on("setvolume", (body: SetVolumeMessage) => { emitter.emit("setvolume", body) });
|
||||
this.emitter.on("setspeed", (body: SetSpeedMessage) => { emitter.emit("setspeed", body) });
|
||||
this.emitter.on("version", (body: VersionMessage) => { emitter.emit("version", body) });
|
||||
this.emitter.on("ping", () => { emitter.emit("ping") });
|
||||
this.emitter.on("pong", () => { emitter.emit("pong") });
|
||||
}
|
||||
}
|
||||
}
|
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,11 +1,12 @@
|
|||
import * as net from 'net';
|
||||
import { FCastSession, Opcode } from './FCastSession';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { dialog } from 'electron';
|
||||
import Main from './Main';
|
||||
import { FCastSession, Opcode } from 'common/FCastSession';
|
||||
import { EventEmitter } from 'events';
|
||||
import { Main, errorHandler } from 'src/Main';
|
||||
import { v4 as uuidv4 } from 'modules/uuid';
|
||||
|
||||
export class TcpListenerService {
|
||||
public static PORT = 46899;
|
||||
private static TIMEOUT = 2500;
|
||||
|
||||
emitter = new EventEmitter();
|
||||
|
||||
|
@ -35,6 +36,7 @@ export class TcpListenerService {
|
|||
}
|
||||
|
||||
send(opcode: number, message = null) {
|
||||
// Main.logger.info(`Sending message ${JSON.stringify(message)}`);
|
||||
this.sessions.forEach(session => {
|
||||
try {
|
||||
session.send(opcode, message);
|
||||
|
@ -46,23 +48,7 @@ export class TcpListenerService {
|
|||
}
|
||||
|
||||
private async handleServerError(err: NodeJS.ErrnoException) {
|
||||
Main.logger.error("Server error:", err);
|
||||
|
||||
const restartPrompt = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Failed to start',
|
||||
message: 'The application failed to start properly.',
|
||||
buttons: ['Restart', 'Close'],
|
||||
defaultId: 0,
|
||||
cancelId: 1
|
||||
});
|
||||
|
||||
if (restartPrompt.response === 0) {
|
||||
Main.application.relaunch();
|
||||
Main.application.exit(0);
|
||||
} else {
|
||||
Main.application.exit(0);
|
||||
}
|
||||
errorHandler(err);
|
||||
}
|
||||
|
||||
private handleConnection(socket: net.Socket) {
|
||||
|
@ -72,6 +58,24 @@ export class TcpListenerService {
|
|||
session.bindEvents(this.emitter);
|
||||
this.sessions.push(session);
|
||||
|
||||
const connectionId = uuidv4();
|
||||
let heartbeatRetries = 0;
|
||||
socket.setTimeout(TcpListenerService.TIMEOUT);
|
||||
socket.on('timeout', () => {
|
||||
try {
|
||||
if (heartbeatRetries > 3) {
|
||||
Main.logger.warn(`Could not ping device ${socket.remoteAddress}:${socket.remotePort}. Disconnecting...`);
|
||||
socket.destroy();
|
||||
}
|
||||
|
||||
heartbeatRetries += 1;
|
||||
session.send(Opcode.Ping);
|
||||
} catch (e) {
|
||||
Main.logger.warn(`Error while pinging sender device ${socket.remoteAddress}:${socket.remotePort}.`, e);
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", (err) => {
|
||||
Main.logger.warn(`Error from ${socket.remoteAddress}:${socket.remotePort}.`, err);
|
||||
socket.destroy();
|
||||
|
@ -79,6 +83,7 @@ export class TcpListenerService {
|
|||
|
||||
socket.on("data", buffer => {
|
||||
try {
|
||||
heartbeatRetries = 0;
|
||||
session.processBytes(buffer);
|
||||
} catch (e) {
|
||||
Main.logger.warn(`Error while handling packet from ${socket.remoteAddress}:${socket.remotePort}.`, e);
|
||||
|
@ -91,8 +96,18 @@ export class TcpListenerService {
|
|||
if (index != -1) {
|
||||
this.sessions.splice(index, 1);
|
||||
}
|
||||
this.emitter.emit('disconnect', { id: connectionId, type: 'tcp', data: { address: socket.remoteAddress, port: socket.remotePort }});
|
||||
this.emitter.removeListener('ping', pingListener);
|
||||
});
|
||||
|
||||
this.emitter.emit('connect', { id: connectionId, type: 'tcp', data: { address: socket.remoteAddress, port: socket.remotePort }});
|
||||
const pingListener = (message: any) => {
|
||||
if (!message) {
|
||||
this.emitter.emit('ping', { id: connectionId });
|
||||
}
|
||||
}
|
||||
this.emitter.prependListener('ping', pingListener);
|
||||
|
||||
try {
|
||||
Main.logger.info('Sending version');
|
||||
session.send(Opcode.Version, {version: 2});
|
||||
|
@ -100,4 +115,4 @@ export class TcpListenerService {
|
|||
Main.logger.info('Failed to send version', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
import { FCastSession, Opcode } from './FCastSession';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { dialog } from 'electron';
|
||||
import Main from './Main';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
import { FCastSession, Opcode } from 'common/FCastSession';
|
||||
import { EventEmitter } from 'events';
|
||||
import { WebSocket, WebSocketServer } from 'modules/ws';
|
||||
import { Main, errorHandler } from 'src/Main';
|
||||
import { v4 as uuidv4 } from 'modules/uuid';
|
||||
|
||||
export class WebSocketListenerService {
|
||||
public static PORT = 46898;
|
||||
|
@ -45,23 +45,7 @@ export class WebSocketListenerService {
|
|||
}
|
||||
|
||||
private async handleServerError(err: NodeJS.ErrnoException) {
|
||||
Main.logger.error("Server error:", err);
|
||||
|
||||
const restartPrompt = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Failed to start',
|
||||
message: 'The application failed to start properly.',
|
||||
buttons: ['Restart', 'Close'],
|
||||
defaultId: 0,
|
||||
cancelId: 1
|
||||
});
|
||||
|
||||
if (restartPrompt.response === 0) {
|
||||
Main.application.relaunch();
|
||||
Main.application.exit(0);
|
||||
} else {
|
||||
Main.application.exit(0);
|
||||
}
|
||||
errorHandler(err);
|
||||
}
|
||||
|
||||
private handleConnection(socket: WebSocket) {
|
||||
|
@ -71,6 +55,8 @@ export class WebSocketListenerService {
|
|||
session.bindEvents(this.emitter);
|
||||
this.sessions.push(session);
|
||||
|
||||
const connectionId = uuidv4();
|
||||
|
||||
socket.on("error", (err) => {
|
||||
Main.logger.warn(`Error.`, err);
|
||||
session.close();
|
||||
|
@ -96,8 +82,18 @@ export class WebSocketListenerService {
|
|||
if (index != -1) {
|
||||
this.sessions.splice(index, 1);
|
||||
}
|
||||
this.emitter.emit('disconnect', { id: connectionId, type: 'ws', data: { url: socket.url }});
|
||||
this.emitter.removeListener('ping', pingListener);
|
||||
});
|
||||
|
||||
this.emitter.emit('connect', { id: connectionId, type: 'ws', data: { url: socket.url }});
|
||||
const pingListener = (message: any) => {
|
||||
if (!message) {
|
||||
this.emitter.emit('ping', { id: connectionId });
|
||||
}
|
||||
}
|
||||
this.emitter.prependListener('ping', pingListener);
|
||||
|
||||
try {
|
||||
Main.logger.info('Sending version');
|
||||
session.send(Opcode.Version, {version: 2});
|
||||
|
@ -105,4 +101,4 @@ export class WebSocketListenerService {
|
|||
Main.logger.info('Failed to send version');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
54
receivers/common/web/components/Toast.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
export enum ToastIcon {
|
||||
INFO,
|
||||
ERROR,
|
||||
}
|
||||
|
||||
const toastQueue = []
|
||||
|
||||
export function toast(message: string, icon: ToastIcon = ToastIcon.INFO, duration: number = 5000) {
|
||||
toastQueue.push({ message: message, icon: icon, duration: duration });
|
||||
|
||||
if (toastQueue.length === 1) {
|
||||
renderToast(message, icon, duration);
|
||||
}
|
||||
}
|
||||
|
||||
function renderToast(message: string, icon: ToastIcon = ToastIcon.INFO, duration: number = 5000) {
|
||||
const toastNotification = document.getElementById('toast-notification');
|
||||
const toastIcon = document.getElementById('toast-icon');
|
||||
const toastText = document.getElementById('toast-text');
|
||||
|
||||
if (!(toastNotification && toastIcon && toastText)) {
|
||||
throw 'Toast component could not be initialized';
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
toastNotification.className = 'toast-fade-out';
|
||||
toastNotification.style.opacity = '0';
|
||||
toastQueue.shift();
|
||||
|
||||
if (toastQueue.length > 0) {
|
||||
window.setTimeout(() => {
|
||||
let toast = toastQueue[0];
|
||||
renderToast(toast.message, toast.icon, toast.duration);
|
||||
}, 1000);
|
||||
}
|
||||
}, duration);
|
||||
|
||||
switch (icon) {
|
||||
case ToastIcon.INFO:
|
||||
toastIcon.style.backgroundImage = 'url(../assets/icons/app/info.svg)';
|
||||
break;
|
||||
|
||||
case ToastIcon.ERROR:
|
||||
toastIcon.style.backgroundImage = 'url(../assets/icons/app/error.svg)';
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
toastText.textContent = message;
|
||||
toastNotification.className = 'toast-fade-in';
|
||||
toastNotification.style.opacity = '1';
|
||||
}
|
62
receivers/common/web/main/Preload.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { toast, ToastIcon } from '../components/Toast';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: any;
|
||||
webOS: any;
|
||||
webOSDev: any;
|
||||
targetAPI: any;
|
||||
}
|
||||
}
|
||||
|
||||
let preloadData: Record<string, any> = {};
|
||||
|
||||
// @ts-ignore
|
||||
if (TARGET === 'electron') {
|
||||
// @ts-ignore
|
||||
const electronAPI = __non_webpack_require__('electron');
|
||||
|
||||
electronAPI.ipcRenderer.on("device-info", (_event, value: any) => {
|
||||
preloadData.deviceInfo = value;
|
||||
})
|
||||
|
||||
electronAPI.contextBridge.exposeInMainWorld('targetAPI', {
|
||||
onDeviceInfo: (callback: any) => electronAPI.ipcRenderer.on("device-info", callback),
|
||||
onConnect: (callback: any) => electronAPI.ipcRenderer.on("connect", callback),
|
||||
onDisconnect: (callback: any) => electronAPI.ipcRenderer.on("disconnect", callback),
|
||||
onPing: (callback: any) => electronAPI.ipcRenderer.on("ping", callback),
|
||||
getDeviceInfo: () => preloadData.deviceInfo,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
} else if (TARGET === 'webOS') {
|
||||
try {
|
||||
preloadData = {
|
||||
onDeviceInfoCb: () => { console.log('Main: Callback not set while fetching device info'); },
|
||||
onConnectCb: (_, value: any) => { console.log('Main: Callback not set while calling onConnect'); },
|
||||
onDisconnectCb: (_, value: any) => { console.log('Main: Callback not set while calling onDisconnect'); },
|
||||
onPingCb: (_, value: any) => { console.log('Main: Callback not set while calling onPing'); },
|
||||
};
|
||||
|
||||
window.targetAPI = {
|
||||
onDeviceInfo: (callback: () => void) => preloadData.onDeviceInfoCb = callback,
|
||||
onConnect: (callback: (_, value: any) => void) => preloadData.onConnectCb = callback,
|
||||
onDisconnect: (callback: (_, value: any) => void) => preloadData.onDisconnectCb = callback,
|
||||
onPing: (callback: (_, value: any) => void) => preloadData.onPingCb = callback,
|
||||
getDeviceInfo: () => preloadData.deviceInfo,
|
||||
};
|
||||
}
|
||||
catch (err) {
|
||||
console.error(`Main: preload ${JSON.stringify(err)}`);
|
||||
toast(`Main: preload ${JSON.stringify(err)}`, ToastIcon.ERROR);
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore
|
||||
console.log(`Attempting to run FCast player on unsupported target: ${TARGET}`);
|
||||
}
|
||||
|
||||
export {
|
||||
preloadData
|
||||
};
|
96
receivers/common/web/main/Renderer.ts
Normal file
|
@ -0,0 +1,96 @@
|
|||
|
||||
import QRCode from 'modules/qrcode';
|
||||
import { onQRCodeRendered } from 'src/main/Renderer';
|
||||
import { toast, ToastIcon } from '../components/Toast';
|
||||
|
||||
const connectionStatusText = document.getElementById("connection-status-text");
|
||||
const connectionStatusSpinner = document.getElementById("connection-spinner");
|
||||
const connectionStatusCheck = document.getElementById("connection-check");
|
||||
let connections = [];
|
||||
|
||||
// Window might be re-created while devices are still connected
|
||||
window.targetAPI.onPing((_event, value: any) => {
|
||||
if (value && connections.length === 0) {
|
||||
connections.push(value.id);
|
||||
onConnect(value.id);
|
||||
}
|
||||
});
|
||||
|
||||
window.targetAPI.onDeviceInfo(renderIPsAndQRCode);
|
||||
window.targetAPI.onConnect((_event, value: any) => {
|
||||
connections.push(value.id);
|
||||
onConnect(value);
|
||||
});
|
||||
window.targetAPI.onDisconnect((_event, value: any) => {
|
||||
console.log(`Device disconnected: ${JSON.stringify(value)}`);
|
||||
const index = connections.indexOf(value.id);
|
||||
if (index != -1) {
|
||||
connections.splice(index, 1);
|
||||
|
||||
if (connections.length === 0) {
|
||||
connectionStatusText.textContent = 'Waiting for a connection';
|
||||
connectionStatusSpinner.style.display = 'inline-block';
|
||||
connectionStatusCheck.style.display = 'none';
|
||||
toast("Device disconnected", ToastIcon.INFO);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if(window.targetAPI.getDeviceInfo()) {
|
||||
console.log("device info already present");
|
||||
renderIPsAndQRCode();
|
||||
}
|
||||
|
||||
function onConnect(value: any) {
|
||||
console.log(`Device connected: ${JSON.stringify(value)}`);
|
||||
connectionStatusText.textContent = 'Connected: Ready to cast';
|
||||
connectionStatusSpinner.style.display = 'none';
|
||||
connectionStatusCheck.style.display = 'inline-block';
|
||||
}
|
||||
|
||||
function renderIPsAndQRCode() {
|
||||
const value = window.targetAPI.getDeviceInfo();
|
||||
console.log("device info", value);
|
||||
|
||||
const ipsElement = document.getElementById('ips');
|
||||
if (ipsElement) {
|
||||
ipsElement.innerHTML = `IPs<br>${value.addresses.join('<br>')}`;
|
||||
}
|
||||
|
||||
const fcastConfig = {
|
||||
name: value.name,
|
||||
addresses: value.addresses,
|
||||
services: [
|
||||
{ port: 46899, type: 0 }, //TCP
|
||||
{ port: 46898, type: 1 }, //WS
|
||||
]
|
||||
};
|
||||
|
||||
const json = JSON.stringify(fcastConfig);
|
||||
let base64 = btoa(json);
|
||||
base64 = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
const url = `fcast://r/${base64}`;
|
||||
console.log("qr", {json, url, base64});
|
||||
|
||||
const qrCodeElement = document.getElementById('qr-code');
|
||||
QRCode.toCanvas(qrCodeElement, url, {
|
||||
margin: 0,
|
||||
width: 256,
|
||||
color: {
|
||||
dark : "#000000",
|
||||
light : "#ffffff",
|
||||
},
|
||||
errorCorrectionLevel : "M",
|
||||
},
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error(`Error rendering QR Code: ${err}`);
|
||||
toast(`Error rendering QR Code: ${err}`, ToastIcon.ERROR);
|
||||
}
|
||||
else {
|
||||
console.log(`Rendered QR Code`);
|
||||
}
|
||||
});
|
||||
|
||||
onQRCodeRendered();
|
||||
}
|
296
receivers/common/web/main/common.css
Normal file
|
@ -0,0 +1,296 @@
|
|||
body, html {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.non-selectable {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
|
||||
background-color: rgba(20, 20, 20, 0.5);
|
||||
padding: 25px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2E2E2E;
|
||||
scrollbar-width: thin;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.card-title-separator {
|
||||
height: 1px;
|
||||
background: #2E2E2E;
|
||||
margin-top: 3px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
#ui-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
gap: 15vw;
|
||||
|
||||
font-family: InterVariable;
|
||||
font-size: 20px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
#title-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#title-text {
|
||||
font-family: Outfit;
|
||||
font-size: 100px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
|
||||
background-image: linear-gradient(180deg, #FFFFFF 5.9%, #D3D3D3 100%);
|
||||
background-clip: text;
|
||||
-webkit-background-clip:text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
#title-icon {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
|
||||
background-image: url(../assets/icons/app/icon.svg);
|
||||
background-size: cover;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
#connection-status {
|
||||
padding: 25px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#main-view {
|
||||
padding: 25px;
|
||||
}
|
||||
|
||||
#manual-connection-info {
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
#manual-connection-info-separator {
|
||||
height: 1px;
|
||||
background: #2E2E2E;
|
||||
margin-top: 3px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
#qr-code {
|
||||
display: flex;
|
||||
margin: 20px auto;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
#scan-to-connect {
|
||||
margin-top: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#connection-status-text, #ips, #automatic-discovery {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
#connection-spinner {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#window-can-be-closed {
|
||||
color: #666666;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
margin-bottom: 20px;
|
||||
|
||||
font-family: InterVariable;
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.lds-ring {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
.lds-ring div {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 8px;
|
||||
border: 8px solid #fff;
|
||||
border-radius: 50%;
|
||||
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||
border-color: #fff transparent transparent transparent;
|
||||
}
|
||||
.lds-ring div:nth-child(1) {
|
||||
animation-delay: -0.45s;
|
||||
}
|
||||
.lds-ring div:nth-child(2) {
|
||||
animation-delay: -0.3s;
|
||||
}
|
||||
.lds-ring div:nth-child(3) {
|
||||
animation-delay: -0.15s;
|
||||
}
|
||||
@keyframes lds-ring {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
#connection-check {
|
||||
/* display: inline-block; */
|
||||
display: none;
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 18px;
|
||||
padding: 10px;
|
||||
|
||||
background-color: #019BE7;
|
||||
border-radius: 50%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
#connection-check-mark {
|
||||
position: relative;
|
||||
top: -10px;
|
||||
left: -10px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
|
||||
animation: check 0.5s cubic-bezier(0.5, 0, 0.5, 1) 1;
|
||||
background-image: url(../assets/icons/app/checked.svg);
|
||||
background-size: cover;
|
||||
background-color: #019BE7;
|
||||
border-radius: 50%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes check {
|
||||
0% {
|
||||
clip-path: inset(0px 64px 0px 0px);
|
||||
}
|
||||
100% {
|
||||
clip-path: inset(0px 0px 0px 0px);
|
||||
}
|
||||
}
|
||||
|
||||
#toast-notification {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
gap: 12px;
|
||||
|
||||
position: relative;
|
||||
top: -200px;
|
||||
max-width: 70%;
|
||||
|
||||
background: #F0F0F0;
|
||||
border: 3px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0px 100px 80px rgba(0, 0, 0, 0.33), 0px 64.8148px 46.8519px rgba(0, 0, 0, 0.250556), 0px 38.5185px 25.4815px rgba(0, 0, 0, 0.200444), 0px 20px 13px rgba(0, 0, 0, 0.165), 0px 8.14815px 6.51852px rgba(0, 0, 0, 0.129556), 0px 1.85185px 3.14815px rgba(0, 0, 0, 0.0794444);
|
||||
border-radius: 12px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#toast-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background-image: url(../assets/icons/app/info.svg);
|
||||
background-size: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#toast-text {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
|
||||
font-family: InterVariable;
|
||||
font-size: 20px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.toast-fade-in {
|
||||
animation: toast-fade-in 1.0s cubic-bezier(0.5, 0, 0.5, 1) 1;
|
||||
}
|
||||
|
||||
.toast-fade-out {
|
||||
animation: toast-fade-out 1.0s cubic-bezier(0.5, 0, 0.5, 1) 1;
|
||||
}
|
||||
|
||||
@keyframes toast-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
|
@ -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,
|
67
receivers/common/web/player/Preload.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage } from 'common/Packets';
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: any;
|
||||
webOSAPI: any;
|
||||
webOS: any;
|
||||
targetAPI: any;
|
||||
}
|
||||
}
|
||||
|
||||
let preloadData: Record<string, any> = {};
|
||||
|
||||
// @ts-ignore
|
||||
if (TARGET === 'electron') {
|
||||
// @ts-ignore
|
||||
const electronAPI = __non_webpack_require__('electron');
|
||||
|
||||
electronAPI.contextBridge.exposeInMainWorld('targetAPI', {
|
||||
sendPlaybackError: (error: PlaybackErrorMessage) => electronAPI.ipcRenderer.send('send-playback-error', error),
|
||||
sendPlaybackUpdate: (update: PlaybackUpdateMessage) => electronAPI.ipcRenderer.send('send-playback-update', update),
|
||||
sendVolumeUpdate: (update: VolumeUpdateMessage) => electronAPI.ipcRenderer.send('send-volume-update', update),
|
||||
onPlay: (callback: any) => electronAPI.ipcRenderer.on("play", callback),
|
||||
onPause: (callback: any) => electronAPI.ipcRenderer.on("pause", callback),
|
||||
onResume: (callback: any) => electronAPI.ipcRenderer.on("resume", callback),
|
||||
onSeek: (callback: any) => electronAPI.ipcRenderer.on("seek", callback),
|
||||
onSetVolume: (callback: any) => electronAPI.ipcRenderer.on("setvolume", callback),
|
||||
onSetSpeed: (callback: any) => electronAPI.ipcRenderer.on("setspeed", callback)
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
} else if (TARGET === 'webOS') {
|
||||
preloadData = {
|
||||
sendPlaybackErrorCb: () => { console.error('Player: Callback "send_playback_error" not set'); },
|
||||
sendPlaybackUpdateCb: () => { console.error('Player: Callback "send_playback_update" not set'); },
|
||||
sendVolumeUpdateCb: () => { console.error('Player: Callback "send_volume_update" not set'); },
|
||||
// onPlayCb: () => { console.error('Player: Callback "play" not set'); },
|
||||
onPlayCb: undefined,
|
||||
onPauseCb: () => { console.error('Player: Callback "pause" not set'); },
|
||||
onResumeCb: () => { console.error('Player: Callback "resume" not set'); },
|
||||
onSeekCb: () => { console.error('Player: Callback "onseek" not set'); },
|
||||
onSetVolumeCb: () => { console.error('Player: Callback "setvolume" not set'); },
|
||||
onSetSpeedCb: () => { console.error('Player: Callback "setspeed" not set'); },
|
||||
};
|
||||
|
||||
window.targetAPI = {
|
||||
sendPlaybackError: (error: PlaybackErrorMessage) => { preloadData.sendPlaybackErrorCb(error); },
|
||||
sendPlaybackUpdate: (update: PlaybackUpdateMessage) => { preloadData.sendPlaybackUpdateCb(update); },
|
||||
sendVolumeUpdate: (update: VolumeUpdateMessage) => { preloadData.sendVolumeUpdateCb(update); },
|
||||
onPlay: (callback: any) => { preloadData.onPlayCb = callback; },
|
||||
onPause: (callback: any) => { preloadData.onPauseCb = callback; },
|
||||
onResume: (callback: any) => { preloadData.onResumeCb = callback; },
|
||||
onSeek: (callback: any) => { preloadData.onSeekCb = callback; },
|
||||
onSetVolume: (callback: any) => { preloadData.onSetVolumeCb = callback; },
|
||||
onSetSpeed: (callback: any) => { preloadData.onSetSpeedCb = callback; }
|
||||
};
|
||||
} else {
|
||||
// @ts-ignore
|
||||
console.log(`Attempting to run FCast player on unsupported target: ${TARGET}`);
|
||||
}
|
||||
|
||||
export {
|
||||
preloadData
|
||||
};
|
828
receivers/common/web/player/Renderer.ts
Normal file
|
@ -0,0 +1,828 @@
|
|||
import dashjs from 'modules/dashjs';
|
||||
import Hls, { LevelLoadedData } from 'modules/hls.js';
|
||||
import { PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage } from 'common/Packets';
|
||||
import { Player, PlayerType } from './Player';
|
||||
import {
|
||||
targetPlayerCtrlStateUpdate,
|
||||
targetKeyDownEventListener,
|
||||
captionsBaseHeightCollapsed,
|
||||
captionsBaseHeightExpanded,
|
||||
captionsLineHeight
|
||||
} from 'src/player/Renderer';
|
||||
|
||||
function formatDuration(duration: number) {
|
||||
const totalSeconds = Math.floor(duration);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(totalSeconds % 60);
|
||||
|
||||
const paddedMinutes = String(minutes).padStart(2, '0');
|
||||
const paddedSeconds = String(seconds).padStart(2, '0');
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${paddedMinutes}:${paddedSeconds}`;
|
||||
} else {
|
||||
return `${paddedMinutes}:${paddedSeconds}`;
|
||||
}
|
||||
}
|
||||
|
||||
function sendPlaybackUpdate(updateState: number) {
|
||||
const updateMessage = new PlaybackUpdateMessage(Date.now(), player.getCurrentTime(), player.getDuration(), updateState, player.getPlaybackRate());
|
||||
|
||||
if (updateMessage.generationTime > lastPlayerUpdateGenerationTime) {
|
||||
lastPlayerUpdateGenerationTime = updateMessage.generationTime;
|
||||
window.targetAPI.sendPlaybackUpdate(updateMessage);
|
||||
}
|
||||
};
|
||||
|
||||
function onPlayerLoad(value: PlayMessage, currentPlaybackRate?: number, currentVolume?: number) {
|
||||
playerCtrlStateUpdate(PlayerControlEvent.Load);
|
||||
|
||||
// Subtitles break when seeking post stream initialization for the DASH player.
|
||||
// Its currently done on player initialization.
|
||||
if (player.playerType === PlayerType.Hls || player.playerType === PlayerType.Html) {
|
||||
if (value.time) {
|
||||
player.setCurrentTime(value.time);
|
||||
}
|
||||
}
|
||||
|
||||
if (value.speed) {
|
||||
player.setPlaybackRate(value.speed);
|
||||
} else if (currentPlaybackRate) {
|
||||
player.setPlaybackRate(currentPlaybackRate);
|
||||
} else {
|
||||
player.setPlaybackRate(1.0);
|
||||
}
|
||||
playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate);
|
||||
|
||||
if (currentVolume) {
|
||||
volumeChangeHandler(currentVolume);
|
||||
}
|
||||
else {
|
||||
// FCast PlayMessage does not contain volume field and could result in the receiver
|
||||
// getting out-of-sync with the sender on 1st playback.
|
||||
volumeChangeHandler(1.0);
|
||||
window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: 1.0 });
|
||||
}
|
||||
|
||||
player.play();
|
||||
}
|
||||
|
||||
// HTML elements
|
||||
const videoElement = document.getElementById("videoPlayer") as HTMLVideoElement;
|
||||
const videoCaptions = document.getElementById("videoCaptions") as HTMLDivElement;
|
||||
|
||||
const playerControls = document.getElementById("controls");
|
||||
|
||||
const playerCtrlAction = document.getElementById("action");
|
||||
const playerCtrlVolume = document.getElementById("volume");
|
||||
|
||||
const playerCtrlProgressBar = document.getElementById("progressBar");
|
||||
const playerCtrlProgressBarBuffer = document.getElementById("progressBarBuffer");
|
||||
const playerCtrlProgressBarProgress = document.getElementById("progressBarProgress");
|
||||
const playerCtrlProgressBarPosition = document.getElementById("progressBarPosition");
|
||||
const playerCtrlProgressBarHandle = document.getElementById("progressBarHandle");
|
||||
const PlayerCtrlProgressBarInteractiveArea = document.getElementById("progressBarInteractiveArea");
|
||||
|
||||
const playerCtrlVolumeBar = document.getElementById("volumeBar");
|
||||
const playerCtrlVolumeBarProgress = document.getElementById("volumeBarProgress");
|
||||
const playerCtrlVolumeBarHandle = document.getElementById("volumeBarHandle");
|
||||
const playerCtrlVolumeBarInteractiveArea = document.getElementById("volumeBarInteractiveArea");
|
||||
|
||||
const playerCtrlLiveBadge = document.getElementById("liveBadge");
|
||||
const playerCtrlPosition = document.getElementById("position");
|
||||
const playerCtrlDurationSeparator = document.getElementById("durationSeparator");
|
||||
const playerCtrlDuration = document.getElementById("duration");
|
||||
|
||||
const playerCtrlCaptions = document.getElementById("captions");
|
||||
const playerCtrlSpeed = document.getElementById("speed");
|
||||
|
||||
const playerCtrlSpeedMenu = document.getElementById("speedMenu");
|
||||
let playerCtrlSpeedMenuShown = false;
|
||||
|
||||
|
||||
const playbackRates = ["0.25", "0.50", "0.75", "1.00", "1.25", "1.50", "1.75", "2.00"];
|
||||
const playbackUpdateInterval = 1.0;
|
||||
const livePositionDelta = 5.0;
|
||||
const livePositionWindow = livePositionDelta * 4;
|
||||
let player: Player;
|
||||
let playerPrevTime: number = 0;
|
||||
let lastPlayerUpdateGenerationTime = 0;
|
||||
let isLive = false;
|
||||
let isLivePosition = false;
|
||||
let captionsBaseHeight = 0;
|
||||
let captionsContentHeight = 0;
|
||||
|
||||
function onPlay(_event, value: PlayMessage) {
|
||||
console.log("Handle play message renderer", JSON.stringify(value));
|
||||
const currentVolume = player ? player.getVolume() : null;
|
||||
const currentPlaybackRate = player ? player.getPlaybackRate() : null;
|
||||
|
||||
playerPrevTime = 0;
|
||||
lastPlayerUpdateGenerationTime = 0;
|
||||
isLive = false;
|
||||
isLivePosition = false;
|
||||
captionsBaseHeight = captionsBaseHeightExpanded;
|
||||
|
||||
if (player) {
|
||||
if (player.getSource() === value.url) {
|
||||
if (value.time) {
|
||||
if (Math.abs(value.time - player.getCurrentTime()) < 5000) {
|
||||
console.warn(`Skipped changing video URL because URL and time is (nearly) unchanged: ${value.url}, ${player.getSource()}, ${formatDuration(value.time)}, ${formatDuration(player.getCurrentTime())}`);
|
||||
} else {
|
||||
console.info(`Skipped changing video URL because URL is the same, but time was changed, seeking instead: ${value.url}, ${player.getSource()}, ${formatDuration(value.time)}, ${formatDuration(player.getCurrentTime())}`);
|
||||
|
||||
player.setCurrentTime(value.time);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
player.destroy();
|
||||
}
|
||||
|
||||
if ((value.url || value.content) && value.container && videoElement) {
|
||||
if (value.container === 'application/dash+xml') {
|
||||
console.log("Loading dash player");
|
||||
const dashPlayer = dashjs.MediaPlayer().create();
|
||||
player = new Player(PlayerType.Dash, dashPlayer);
|
||||
|
||||
dashPlayer.extend("RequestModifier", () => {
|
||||
return {
|
||||
modifyRequestHeader: function (xhr) {
|
||||
if (value.headers) {
|
||||
for (const [key, val] of Object.entries(value.headers)) {
|
||||
xhr.setRequestHeader(key, val);
|
||||
}
|
||||
}
|
||||
|
||||
return xhr;
|
||||
}
|
||||
};
|
||||
}, true);
|
||||
|
||||
// Player event handlers
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PLAYING, () => { sendPlaybackUpdate(1); playerCtrlStateUpdate(PlayerControlEvent.Play); });
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PAUSED, () => { sendPlaybackUpdate(2); playerCtrlStateUpdate(PlayerControlEvent.Pause); });
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ENDED, () => { sendPlaybackUpdate(0) });
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_TIME_UPDATED, () => {
|
||||
playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate);
|
||||
|
||||
if (Math.abs(dashPlayer.time() - playerPrevTime) >= playbackUpdateInterval) {
|
||||
sendPlaybackUpdate(dashPlayer.isPaused() ? 2 : 1);
|
||||
playerPrevTime = dashPlayer.time();
|
||||
}
|
||||
});
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_RATE_CHANGED, () => { sendPlaybackUpdate(dashPlayer.isPaused() ? 2 : 1) });
|
||||
|
||||
// Buffering UI update when paused
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PROGRESS, () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); });
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_VOLUME_CHANGED, () => {
|
||||
const updateVolume = dashPlayer.isMuted() ? 0 : dashPlayer.getVolume();
|
||||
playerCtrlStateUpdate(PlayerControlEvent.VolumeChange);
|
||||
window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume });
|
||||
});
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.ERROR, (data) => { window.targetAPI.sendPlaybackError({
|
||||
message: `DashJS ERROR: ${JSON.stringify(data)}`
|
||||
})});
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ERROR, (data) => { window.targetAPI.sendPlaybackError({
|
||||
message: `DashJS PLAYBACK_ERROR: ${JSON.stringify(data)}`
|
||||
})});
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.STREAM_INITIALIZED, () => { onPlayerLoad(value, currentPlaybackRate, currentVolume); });
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.CUE_ENTER, (e: any) => {
|
||||
const subtitle = document.createElement("p")
|
||||
subtitle.setAttribute("id", "subtitle-" + e.cueID)
|
||||
|
||||
subtitle.textContent = e.text;
|
||||
videoCaptions.appendChild(subtitle);
|
||||
|
||||
captionsContentHeight = subtitle.getBoundingClientRect().height - captionsLineHeight;
|
||||
const captionsHeight = captionsBaseHeight + captionsContentHeight;
|
||||
|
||||
if (player.isCaptionsEnabled()) {
|
||||
videoCaptions.setAttribute("style", `display: block; bottom: ${captionsHeight}px;`);
|
||||
} else {
|
||||
videoCaptions.setAttribute("style", `display: none; bottom: ${captionsHeight}px;`);
|
||||
}
|
||||
});
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.CUE_EXIT, (e: any) => {
|
||||
document.getElementById("subtitle-" + e.cueID)?.remove();
|
||||
});
|
||||
|
||||
dashPlayer.updateSettings({
|
||||
// debug: {
|
||||
// logLevel: dashjs.LogLevel.LOG_LEVEL_INFO
|
||||
// },
|
||||
streaming: {
|
||||
text: {
|
||||
dispatchForManualRendering: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (value.content) {
|
||||
dashPlayer.initialize(videoElement, `data:${value.container};base64,` + window.btoa(value.content), true, value.time);
|
||||
// dashPlayer.initialize(videoElement, "https://dash.akamaized.net/akamai/test/caption_test/ElephantsDream/elephants_dream_480p_heaac5_1_https.mpd", true);
|
||||
} else {
|
||||
// value.url = 'https://dash.akamaized.net/akamai/bbb_30fps/bbb_30fps.mpd';
|
||||
dashPlayer.initialize(videoElement, value.url, true, value.time);
|
||||
}
|
||||
|
||||
} else if ((value.container === 'application/vnd.apple.mpegurl' || value.container === 'application/x-mpegURL') && !videoElement.canPlayType(value.container)) {
|
||||
console.log("Loading hls player");
|
||||
|
||||
const config = {
|
||||
xhrSetup: function (xhr: XMLHttpRequest) {
|
||||
if (value.headers) {
|
||||
for (const [key, val] of Object.entries(value.headers)) {
|
||||
xhr.setRequestHeader(key, val);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const hlsPlayer = new Hls(config);
|
||||
|
||||
hlsPlayer.on(Hls.Events.ERROR, (eventName, data) => {
|
||||
window.targetAPI.sendPlaybackError({
|
||||
message: `HLS player error: ${JSON.stringify(data)}`
|
||||
});
|
||||
});
|
||||
|
||||
hlsPlayer.on(Hls.Events.LEVEL_LOADED, (eventName, level: LevelLoadedData) => {
|
||||
isLive = level.details.live;
|
||||
isLivePosition = isLive ? true : false;
|
||||
|
||||
// Event can fire after video load and play initialization
|
||||
if (isLive && playerCtrlLiveBadge.style.display === "none") {
|
||||
playerCtrlLiveBadge.style.display = "block";
|
||||
playerCtrlPosition.style.display = "none";
|
||||
playerCtrlDurationSeparator.style.display = "none";
|
||||
playerCtrlDuration.style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
player = new Player(PlayerType.Hls, videoElement, hlsPlayer);
|
||||
|
||||
// value.url = "https://devstreaming-cdn.apple.com/videos/streaming/examples/adv_dv_atmos/main.m3u8?ref=developerinsider.co";
|
||||
hlsPlayer.loadSource(value.url);
|
||||
hlsPlayer.attachMedia(videoElement);
|
||||
// hlsPlayer.subtitleDisplay = true;
|
||||
|
||||
} else {
|
||||
console.log("Loading html player");
|
||||
player = new Player(PlayerType.Html, videoElement);
|
||||
|
||||
videoElement.src = value.url;
|
||||
videoElement.load();
|
||||
}
|
||||
|
||||
// Player event handlers
|
||||
if (player.playerType === PlayerType.Hls || player.playerType === PlayerType.Html) {
|
||||
videoElement.onplay = () => { sendPlaybackUpdate(1); playerCtrlStateUpdate(PlayerControlEvent.Play); };
|
||||
videoElement.onpause = () => { sendPlaybackUpdate(2); playerCtrlStateUpdate(PlayerControlEvent.Pause); };
|
||||
videoElement.onended = () => { sendPlaybackUpdate(0) };
|
||||
videoElement.ontimeupdate = () => {
|
||||
playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate);
|
||||
|
||||
if (Math.abs(videoElement.currentTime - playerPrevTime) >= playbackUpdateInterval) {
|
||||
sendPlaybackUpdate(videoElement.paused ? 2 : 1);
|
||||
playerPrevTime = videoElement.currentTime;
|
||||
}
|
||||
};
|
||||
// Buffering UI update when paused
|
||||
videoElement.onprogress = () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); };
|
||||
videoElement.onratechange = () => { sendPlaybackUpdate(videoElement.paused ? 2 : 1) };
|
||||
videoElement.onvolumechange = () => {
|
||||
const updateVolume = videoElement.muted ? 0 : videoElement.volume;
|
||||
playerCtrlStateUpdate(PlayerControlEvent.VolumeChange);
|
||||
window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume });
|
||||
};
|
||||
|
||||
videoElement.onerror = (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) => {
|
||||
console.error("Player error", {source, lineno, colno, error});
|
||||
};
|
||||
|
||||
videoElement.onloadedmetadata = (ev) => {
|
||||
if (videoElement.duration === Infinity) {
|
||||
isLive = true;
|
||||
isLivePosition = true;
|
||||
}
|
||||
else {
|
||||
isLive = false;
|
||||
isLivePosition = false;
|
||||
}
|
||||
|
||||
onPlayerLoad(value, currentPlaybackRate, currentVolume); };
|
||||
}
|
||||
}
|
||||
|
||||
// Sender generated event handlers
|
||||
window.targetAPI.onPause(() => { player.pause(); });
|
||||
window.targetAPI.onResume(() => { player.play(); });
|
||||
window.targetAPI.onSeek((_event, value: SeekMessage) => { player.setCurrentTime(value.time); });
|
||||
window.targetAPI.onSetVolume((_event, value: SetVolumeMessage) => { volumeChangeHandler(value.volume); });
|
||||
window.targetAPI.onSetSpeed((_event, value: SetSpeedMessage) => { player.setPlaybackRate(value.speed); playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate); });
|
||||
};
|
||||
|
||||
window.targetAPI.onPlay(onPlay);
|
||||
|
||||
let scrubbing = false;
|
||||
let volumeChanging = false;
|
||||
|
||||
enum PlayerControlEvent {
|
||||
Load,
|
||||
Pause,
|
||||
Play,
|
||||
VolumeChange,
|
||||
TimeUpdate,
|
||||
UiFadeOut,
|
||||
UiFadeIn,
|
||||
SetCaptions,
|
||||
ToggleSpeedMenu,
|
||||
SetPlaybackRate,
|
||||
ToggleFullscreen,
|
||||
ExitFullscreen,
|
||||
}
|
||||
|
||||
// UI update handlers
|
||||
function playerCtrlStateUpdate(event: PlayerControlEvent) {
|
||||
const handledCase = targetPlayerCtrlStateUpdate(event);
|
||||
if (handledCase) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event) {
|
||||
case PlayerControlEvent.Load: {
|
||||
playerCtrlProgressBarBuffer.setAttribute("style", "width: 0px");
|
||||
playerCtrlProgressBarProgress.setAttribute("style", "width: 0px");
|
||||
playerCtrlProgressBarHandle.setAttribute("style", `left: ${playerCtrlProgressBar.offsetLeft}px`);
|
||||
|
||||
const volume = Math.round(player.getVolume() * playerCtrlVolumeBar.offsetWidth);
|
||||
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
|
||||
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume + 8}px`);
|
||||
|
||||
if (isLive) {
|
||||
playerCtrlLiveBadge.setAttribute("style", "display: block");
|
||||
playerCtrlPosition.setAttribute("style", "display: none");
|
||||
playerCtrlDurationSeparator.setAttribute("style", "display: none");
|
||||
playerCtrlDuration.setAttribute("style", "display: none");
|
||||
}
|
||||
else {
|
||||
playerCtrlLiveBadge.setAttribute("style", "display: none");
|
||||
playerCtrlPosition.setAttribute("style", "display: block");
|
||||
playerCtrlDurationSeparator.setAttribute("style", "display: block");
|
||||
playerCtrlDuration.setAttribute("style", "display: block");
|
||||
playerCtrlPosition.textContent = formatDuration(player.getCurrentTime());
|
||||
playerCtrlDuration.innerHTML = formatDuration(player.getDuration());
|
||||
}
|
||||
|
||||
if (player.isCaptionsSupported()) {
|
||||
playerCtrlCaptions.setAttribute("style", "display: block");
|
||||
videoCaptions.setAttribute("style", "display: block");
|
||||
}
|
||||
else {
|
||||
playerCtrlCaptions.setAttribute("style", "display: none");
|
||||
videoCaptions.setAttribute("style", "display: none");
|
||||
player.enableCaptions(false);
|
||||
}
|
||||
playerCtrlStateUpdate(PlayerControlEvent.SetCaptions);
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.Pause:
|
||||
playerCtrlAction.setAttribute("class", "play iconSize");
|
||||
stopUiHideTimer();
|
||||
break;
|
||||
|
||||
case PlayerControlEvent.Play:
|
||||
playerCtrlAction.setAttribute("class", "pause iconSize");
|
||||
startUiHideTimer();
|
||||
break;
|
||||
|
||||
case PlayerControlEvent.VolumeChange: {
|
||||
// console.log(`VolumeChange: isMute ${player.isMuted()}, volume: ${player.getVolume()}`);
|
||||
const volume = Math.round(player.getVolume() * playerCtrlVolumeBar.offsetWidth);
|
||||
|
||||
if (player.isMuted()) {
|
||||
playerCtrlVolume.setAttribute("class", "mute iconSize");
|
||||
playerCtrlVolumeBarProgress.setAttribute("style", `width: 0px`);
|
||||
playerCtrlVolumeBarHandle.setAttribute("style", `left: 0px`);
|
||||
}
|
||||
else if (player.getVolume() >= 0.5) {
|
||||
playerCtrlVolume.setAttribute("class", "volume_high iconSize");
|
||||
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
|
||||
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume}px`);
|
||||
} else {
|
||||
playerCtrlVolume.setAttribute("class", "volume_low iconSize");
|
||||
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
|
||||
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume}px`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.TimeUpdate: {
|
||||
// console.log(`TimeUpdate: Position: ${player.getCurrentTime()}, Duration: ${player.getDuration()}`);
|
||||
|
||||
if (isLive) {
|
||||
if (isLivePosition && player.getDuration() - player.getCurrentTime() > livePositionWindow) {
|
||||
isLivePosition = false;
|
||||
playerCtrlLiveBadge.setAttribute("style", `background-color: #595959`);
|
||||
}
|
||||
else if (!isLivePosition && player.getDuration() - player.getCurrentTime() <= livePositionWindow) {
|
||||
isLivePosition = true;
|
||||
playerCtrlLiveBadge.setAttribute("style", `background-color: red`);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLivePosition) {
|
||||
playerCtrlProgressBarProgress.setAttribute("style", `width: ${playerCtrlProgressBar.offsetWidth}px`);
|
||||
playerCtrlProgressBarHandle.setAttribute("style", `left: ${playerCtrlProgressBar.offsetWidth + playerCtrlProgressBar.offsetLeft}px`);
|
||||
}
|
||||
else {
|
||||
const buffer = Math.round((player.getBufferLength() / player.getDuration()) * playerCtrlProgressBar.offsetWidth);
|
||||
const progress = Math.round((player.getCurrentTime() / player.getDuration()) * playerCtrlProgressBar.offsetWidth);
|
||||
const handle = progress + playerCtrlProgressBar.offsetLeft;
|
||||
|
||||
playerCtrlProgressBarBuffer.setAttribute("style", `width: ${buffer}px`);
|
||||
playerCtrlProgressBarProgress.setAttribute("style", `width: ${progress}px`);
|
||||
playerCtrlProgressBarHandle.setAttribute("style", `left: ${handle}px`);
|
||||
|
||||
playerCtrlPosition.textContent = formatDuration(player.getCurrentTime());
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.UiFadeOut: {
|
||||
document.body.style.cursor = "none";
|
||||
playerControls.setAttribute("style", "opacity: 0");
|
||||
captionsBaseHeight = captionsBaseHeightCollapsed;
|
||||
const captionsHeight = captionsBaseHeight + captionsContentHeight;
|
||||
|
||||
if (player.isCaptionsEnabled()) {
|
||||
videoCaptions.setAttribute("style", `display: block; transition: bottom 0.2s ease-in-out; bottom: ${captionsHeight}px;`);
|
||||
} else {
|
||||
videoCaptions.setAttribute("style", `display: none; bottom: ${captionsHeight}px;`);
|
||||
}
|
||||
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.UiFadeIn: {
|
||||
document.body.style.cursor = "default";
|
||||
playerControls.setAttribute("style", "opacity: 1");
|
||||
captionsBaseHeight = captionsBaseHeightExpanded;
|
||||
const captionsHeight = captionsBaseHeight + captionsContentHeight;
|
||||
|
||||
if (player.isCaptionsEnabled()) {
|
||||
videoCaptions.setAttribute("style", `display: block; transition: bottom 0.2s ease-in-out; bottom: ${captionsHeight}px;`);
|
||||
} else {
|
||||
videoCaptions.setAttribute("style", `display: none; bottom: ${captionsHeight}px;`);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.SetCaptions:
|
||||
if (player.isCaptionsEnabled()) {
|
||||
playerCtrlCaptions.setAttribute("class", "captions_on iconSize");
|
||||
videoCaptions.setAttribute("style", "display: block");
|
||||
} else {
|
||||
playerCtrlCaptions.setAttribute("class", "captions_off iconSize");
|
||||
videoCaptions.setAttribute("style", "display: none");
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case PlayerControlEvent.ToggleSpeedMenu: {
|
||||
if (playerCtrlSpeedMenuShown) {
|
||||
playerCtrlSpeedMenu.setAttribute("style", "display: none");
|
||||
} else {
|
||||
playerCtrlSpeedMenu.setAttribute("style", "display: block");
|
||||
}
|
||||
|
||||
playerCtrlSpeedMenuShown = !playerCtrlSpeedMenuShown;
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.SetPlaybackRate: {
|
||||
const rate = player.getPlaybackRate().toFixed(2);
|
||||
const entryElement = document.getElementById(`speedMenuEntry_${rate}_enabled`);
|
||||
|
||||
playbackRates.forEach(r => {
|
||||
const entry = document.getElementById(`speedMenuEntry_${r}_enabled`);
|
||||
entry.setAttribute("style", "opacity: 0");
|
||||
});
|
||||
|
||||
// Ignore updating GUI for custom rates
|
||||
if (entryElement !== null) {
|
||||
entryElement.setAttribute("style", "opacity: 1");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function scrubbingMouseUIHandler(e: MouseEvent) {
|
||||
const progressBarOffset = e.offsetX - playerCtrlProgressBar.offsetLeft;
|
||||
const progressBarWidth = PlayerCtrlProgressBarInteractiveArea.offsetWidth - (playerCtrlProgressBar.offsetLeft * 2);
|
||||
let time = isLive ? Math.round((1 - (progressBarOffset / progressBarWidth)) * player.getDuration()) : Math.round((progressBarOffset / progressBarWidth) * player.getDuration());
|
||||
time = Math.min(player.getDuration(), Math.max(0.0, time));
|
||||
|
||||
if (scrubbing && isLive && e.buttons === 1) {
|
||||
isLivePosition = false;
|
||||
playerCtrlLiveBadge.setAttribute("style", `background-color: #595959`);
|
||||
}
|
||||
|
||||
const livePrefix = isLive && Math.floor(time) !== 0 ? "-" : "";
|
||||
playerCtrlProgressBarPosition.textContent = isLive ? `${livePrefix}${formatDuration(time)}` : formatDuration(time);
|
||||
|
||||
let offset = e.offsetX - (playerCtrlProgressBarPosition.offsetWidth / 2);
|
||||
offset = Math.min(PlayerCtrlProgressBarInteractiveArea.offsetWidth - (playerCtrlProgressBarPosition.offsetWidth / 1), Math.max(8, offset));
|
||||
playerCtrlProgressBarPosition.setAttribute("style", `display: block; left: ${offset}px`);
|
||||
}
|
||||
|
||||
// Receiver generated event handlers
|
||||
playerCtrlAction.onclick = () => {
|
||||
if (player.isPaused()) {
|
||||
player.play();
|
||||
} else {
|
||||
player.pause();
|
||||
}
|
||||
};
|
||||
|
||||
playerCtrlVolume.onclick = () => { player.setMute(!player.isMuted()); };
|
||||
|
||||
PlayerCtrlProgressBarInteractiveArea.onmousedown = (e: MouseEvent) => { scrubbing = true; scrubbingMouseHandler(e) };
|
||||
PlayerCtrlProgressBarInteractiveArea.onmouseup = () => { scrubbing = false; };
|
||||
PlayerCtrlProgressBarInteractiveArea.onmouseenter = (e: MouseEvent) => {
|
||||
if (e.buttons === 0) {
|
||||
volumeChanging = false;
|
||||
}
|
||||
|
||||
scrubbingMouseUIHandler(e);
|
||||
};
|
||||
PlayerCtrlProgressBarInteractiveArea.onmouseleave = () => { playerCtrlProgressBarPosition.setAttribute("style", "display: none"); };
|
||||
PlayerCtrlProgressBarInteractiveArea.onmousemove = (e: MouseEvent) => { scrubbingMouseHandler(e) };
|
||||
|
||||
function scrubbingMouseHandler(e: MouseEvent) {
|
||||
const progressBarOffset = e.offsetX - playerCtrlProgressBar.offsetLeft;
|
||||
const progressBarWidth = PlayerCtrlProgressBarInteractiveArea.offsetWidth - (playerCtrlProgressBar.offsetLeft * 2);
|
||||
let time = Math.round((progressBarOffset / progressBarWidth) * player.getDuration());
|
||||
time = Math.min(player.getDuration(), Math.max(0.0, time));
|
||||
|
||||
if (scrubbing && e.buttons === 1) {
|
||||
player.setCurrentTime(time);
|
||||
}
|
||||
|
||||
scrubbingMouseUIHandler(e);
|
||||
}
|
||||
|
||||
playerCtrlVolumeBarInteractiveArea.onmousedown = (e: MouseEvent) => { volumeChanging = true; volumeChangeMouseHandler(e) };
|
||||
playerCtrlVolumeBarInteractiveArea.onmouseup = () => { volumeChanging = false; };
|
||||
playerCtrlVolumeBarInteractiveArea.onmouseenter = (e: MouseEvent) => {
|
||||
if (e.buttons === 0) {
|
||||
scrubbing = false;
|
||||
}
|
||||
};
|
||||
playerCtrlVolumeBarInteractiveArea.onmousemove = (e: MouseEvent) => { volumeChangeMouseHandler(e) };
|
||||
playerCtrlVolumeBarInteractiveArea.onwheel = (e: WheelEvent) => {
|
||||
const delta = -e.deltaY;
|
||||
|
||||
if (delta > 0 ) {
|
||||
volumeChangeHandler(Math.min(player.getVolume() + volumeIncrement, 1));
|
||||
} else if (delta < 0) {
|
||||
volumeChangeHandler(Math.max(player.getVolume() - volumeIncrement, 0));
|
||||
}
|
||||
};
|
||||
|
||||
function volumeChangeMouseHandler(e: MouseEvent) {
|
||||
if (volumeChanging && e.buttons === 1) {
|
||||
const volumeBarOffsetX = e.offsetX - playerCtrlVolumeBar.offsetLeft;
|
||||
const volumeBarWidth = playerCtrlVolumeBarInteractiveArea.offsetWidth - (playerCtrlVolumeBar.offsetLeft * 2);
|
||||
const volume = volumeBarOffsetX / volumeBarWidth;
|
||||
volumeChangeHandler(volume);
|
||||
}
|
||||
}
|
||||
|
||||
function volumeChangeHandler(volume: number) {
|
||||
if (!player.isMuted() && volume <= 0) {
|
||||
player.setMute(true);
|
||||
}
|
||||
else if (player.isMuted() && volume > 0) {
|
||||
player.setMute(false);
|
||||
}
|
||||
|
||||
player.setVolume(volume);
|
||||
}
|
||||
|
||||
playerCtrlLiveBadge.onclick = () => { setLivePosition(); };
|
||||
|
||||
function setLivePosition() {
|
||||
if (!isLivePosition) {
|
||||
isLivePosition = true;
|
||||
|
||||
player.setCurrentTime(player.getDuration() - livePositionDelta);
|
||||
playerCtrlLiveBadge.setAttribute("style", `background-color: red`);
|
||||
|
||||
if (player.isPaused()) {
|
||||
player.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playerCtrlCaptions.onclick = () => { player.enableCaptions(!player.isCaptionsEnabled()); playerCtrlStateUpdate(PlayerControlEvent.SetCaptions); };
|
||||
playerCtrlSpeed.onclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu); };
|
||||
|
||||
playbackRates.forEach(r => {
|
||||
const entry = document.getElementById(`speedMenuEntry_${r}`);
|
||||
entry.onclick = () => {
|
||||
player.setPlaybackRate(parseFloat(r));
|
||||
playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate);
|
||||
playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu);
|
||||
};
|
||||
});
|
||||
|
||||
videoElement.onclick = () => {
|
||||
if (!playerCtrlSpeedMenuShown) {
|
||||
if (player.isPaused()) {
|
||||
player.play();
|
||||
} else {
|
||||
player.pause();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Component hiding
|
||||
let uiHideTimer = null;
|
||||
let uiVisible = true;
|
||||
|
||||
function startUiHideTimer() {
|
||||
if (uiHideTimer === null) {
|
||||
uiHideTimer = window.setTimeout(() => {
|
||||
uiHideTimer = null;
|
||||
uiVisible = false;
|
||||
playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function stopUiHideTimer() {
|
||||
if (uiHideTimer) {
|
||||
window.clearTimeout(uiHideTimer);
|
||||
uiHideTimer = null;
|
||||
}
|
||||
|
||||
if (!uiVisible) {
|
||||
uiVisible = true;
|
||||
playerCtrlStateUpdate(PlayerControlEvent.UiFadeIn);
|
||||
}
|
||||
}
|
||||
|
||||
document.onmouseout = () => {
|
||||
if (uiHideTimer) {
|
||||
window.clearTimeout(uiHideTimer);
|
||||
uiHideTimer = null;
|
||||
}
|
||||
|
||||
uiVisible = false;
|
||||
playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut);
|
||||
}
|
||||
|
||||
document.onmousemove = () => {
|
||||
stopUiHideTimer();
|
||||
|
||||
if (player && !player.isPaused()) {
|
||||
startUiHideTimer();
|
||||
}
|
||||
};
|
||||
|
||||
window.onresize = () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); };
|
||||
|
||||
// Listener for hiding speed menu when clicking outside element
|
||||
document.addEventListener('click', (event: MouseEvent) => {
|
||||
const node = event.target as Node;
|
||||
if (playerCtrlSpeedMenuShown && !playerCtrlSpeed.contains(node) && !playerCtrlSpeedMenu.contains(node)){
|
||||
playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu);
|
||||
}
|
||||
});
|
||||
|
||||
// Add the keydown event listener to the document
|
||||
const skipInterval = 10;
|
||||
const volumeIncrement = 0.1;
|
||||
|
||||
function keyDownEventListener(event: any) {
|
||||
// console.log("KeyDown", event);
|
||||
const handledCase = targetKeyDownEventListener(event);
|
||||
if (handledCase) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.code) {
|
||||
case 'KeyF':
|
||||
case 'F11':
|
||||
playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen);
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'Escape':
|
||||
playerCtrlStateUpdate(PlayerControlEvent.ExitFullscreen);
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
skipBack();
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
skipForward();
|
||||
event.preventDefault();
|
||||
break;
|
||||
case "Home":
|
||||
player.setCurrentTime(0);
|
||||
event.preventDefault();
|
||||
break;
|
||||
case "End":
|
||||
if (isLive) {
|
||||
setLivePosition();
|
||||
}
|
||||
else {
|
||||
player.setCurrentTime(player.getDuration());
|
||||
}
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'KeyK':
|
||||
case 'Space':
|
||||
case 'Enter':
|
||||
// Play/pause toggle
|
||||
if (player.isPaused()) {
|
||||
player.play();
|
||||
} else {
|
||||
player.pause();
|
||||
}
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'KeyM':
|
||||
// Mute toggle
|
||||
player.setMute(!player.isMuted());
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
// Volume up
|
||||
volumeChangeHandler(Math.min(player.getVolume() + volumeIncrement, 1));
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
// Volume down
|
||||
volumeChangeHandler(Math.max(player.getVolume() - volumeIncrement, 0));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function skipBack() {
|
||||
player.setCurrentTime(Math.max(player.getCurrentTime() - skipInterval, 0));
|
||||
}
|
||||
|
||||
function skipForward() {
|
||||
if (!isLivePosition) {
|
||||
player.setCurrentTime(Math.min(player.getCurrentTime() + skipInterval, player.getDuration()));
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', keyDownEventListener);
|
||||
|
||||
export {
|
||||
PlayerControlEvent,
|
||||
videoElement,
|
||||
videoCaptions,
|
||||
playerCtrlProgressBar,
|
||||
playerCtrlProgressBarBuffer,
|
||||
playerCtrlProgressBarProgress,
|
||||
playerCtrlProgressBarHandle,
|
||||
playerCtrlVolumeBar,
|
||||
playerCtrlVolumeBarProgress,
|
||||
playerCtrlVolumeBarHandle,
|
||||
playerCtrlLiveBadge,
|
||||
playerCtrlPosition,
|
||||
playerCtrlDuration,
|
||||
playerCtrlCaptions,
|
||||
player,
|
||||
isLive,
|
||||
captionsBaseHeight,
|
||||
captionsLineHeight,
|
||||
onPlay,
|
||||
playerCtrlStateUpdate,
|
||||
formatDuration,
|
||||
skipBack,
|
||||
skipForward,
|
||||
};
|
457
receivers/common/web/player/common.css
Normal file
|
@ -0,0 +1,457 @@
|
|||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: black;
|
||||
color: white;
|
||||
width: 100vw;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
#videoPlayer {
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
*:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
|
||||
/* height: 100%; */
|
||||
height: 120px;
|
||||
width: 100%;
|
||||
/* background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 100%); */
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.0) 35%);
|
||||
|
||||
background-size: 100% 300px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: bottom;
|
||||
|
||||
opacity: 1;
|
||||
transition: opacity 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.iconSize {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.volumeContainer {
|
||||
position: relative;
|
||||
height: 24px;
|
||||
width: 92px;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.volumeBar {
|
||||
position: absolute;
|
||||
/* left: 12px; */
|
||||
left: 8px;
|
||||
top: 10px;
|
||||
height: 4px;
|
||||
/* width: 72px; */
|
||||
width: 76px;
|
||||
background-color: #999999;
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.volumeBarInteractiveArea {
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
/* left: 8px; */
|
||||
top: 0px;
|
||||
height: 24px;
|
||||
width: 92px;
|
||||
/* width: 84px; */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.volumeBarHandle {
|
||||
position: absolute;
|
||||
left: 84px;
|
||||
top: 4px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
/* background-color: #ffffff; */
|
||||
background-color: #c9c9c9;
|
||||
box-shadow: 0px 32px 64px 0px rgba(0, 0, 0, 0.56), 0px 2px 21px 0px rgba(0, 0, 0, 0.55);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.volumeBarProgress {
|
||||
position: absolute;
|
||||
/* left: 12px; */
|
||||
left: 8px;
|
||||
top: 10px;
|
||||
height: 4px;
|
||||
width: 76px;
|
||||
/* background-color: #ffffff; */
|
||||
background-color: #c9c9c9;
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.progressBarContainer {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
height: 4px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.progressBarInteractiveArea {
|
||||
position: absolute;
|
||||
/* bottom: 60px; */
|
||||
/* left: 24px; */
|
||||
/* right: 24px; */
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
left: 0px;
|
||||
bottom: 0px;
|
||||
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
z-index: 999;
|
||||
}
|
||||
.progressBarChapterContainer {
|
||||
position: absolute;
|
||||
bottom: 73px;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
height: 4px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
/* position: absolute; */
|
||||
position: relative;
|
||||
/* bottom: 70px; */
|
||||
/* left: 24px; */
|
||||
/* right: 24px; */
|
||||
left: 8px;
|
||||
width: calc(100% - 16px);
|
||||
height: 4px;
|
||||
background-color: #99999945;
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.progressBarBuffer {
|
||||
/* position: absolute; */
|
||||
position: relative;
|
||||
/* bottom: 70px; */
|
||||
/* left: 24px; */
|
||||
left: 8px;
|
||||
bottom: 4px;
|
||||
height: 4px;
|
||||
background-color: #D9D9D945;
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.progressBarProgress {
|
||||
/* position: absolute; */
|
||||
position: relative;
|
||||
/* bottom: 70px; */
|
||||
/* left: 24px; */
|
||||
left: 8px;
|
||||
bottom: 8px;
|
||||
height: 4px;
|
||||
width: 0px;
|
||||
background-color: #019BE7;
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.progressBarPosition {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 25px;
|
||||
padding: 2px 5px;
|
||||
|
||||
font-family: InterVariable;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
|
||||
border-radius: 3px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.progressBarHandle {
|
||||
position: absolute;
|
||||
/* bottom: 70px; */
|
||||
bottom: 10px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: -8px;
|
||||
margin-bottom: -8px;
|
||||
background-color: #019BE7;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.positionContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
|
||||
font-family: InterVariable;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
|
||||
.position {
|
||||
margin-right: 10px;
|
||||
vertical-align: bottom;
|
||||
|
||||
color: #c9c9c9;
|
||||
}
|
||||
|
||||
.duration {
|
||||
opacity: 0.6;
|
||||
|
||||
color: #c9c9c9;
|
||||
}
|
||||
|
||||
.liveBadge {
|
||||
background-color: red;
|
||||
/* margin-top: -2px; */
|
||||
/* padding: 5px 5px; */
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
/* margin-left: 10px; */
|
||||
margin-right: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.play {
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
background-image: url("../assets/icons/player/icon24_play.svg");
|
||||
transition: background-image 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.play:hover {
|
||||
background-image: url("../assets/icons/player/icon24_play_active.svg");
|
||||
}
|
||||
|
||||
.pause {
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
background-image: url("../assets/icons/player/icon24_pause.svg");
|
||||
transition: background-image 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.pause:hover {
|
||||
background-image: url("../assets/icons/player/icon24_pause_active.svg");
|
||||
}
|
||||
|
||||
.volume_high {
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
background-image: url("../assets/icons/player/icon24_volume_more_50pct.svg");
|
||||
transition: background-image 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.volume_high:hover {
|
||||
background-image: url("../assets/icons/player/icon24_volume_more_50pct_active.svg");
|
||||
}
|
||||
|
||||
.volume_low {
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
background-image: url("../assets/icons/player/icon24_volume_less_50pct.svg");
|
||||
transition: background-image 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.volume_low:hover {
|
||||
background-image: url("../assets/icons/player/icon24_volume_less_50pct_active.svg");
|
||||
}
|
||||
|
||||
.mute {
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
background-image: url("../assets/icons/player/icon24_mute.svg");
|
||||
transition: background-image 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.mute:hover {
|
||||
background-image: url("../assets/icons/player/icon24_mute_active.svg");
|
||||
}
|
||||
|
||||
.speed {
|
||||
cursor: pointer;
|
||||
|
||||
background-image: url("../assets/icons/player/icon24_speed.svg");
|
||||
transition: background-image 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.speed:hover {
|
||||
background-image: url("../assets/icons/player/icon24_speed_active.svg");
|
||||
}
|
||||
|
||||
.captions_off {
|
||||
cursor: pointer;
|
||||
|
||||
background-image: url("../assets/icons/player/icon24_cc_off.svg");
|
||||
transition: background-image 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.captions_off:hover {
|
||||
background-image: url("../assets/icons/player/icon24_cc_off_active.svg");
|
||||
}
|
||||
|
||||
.captions_on {
|
||||
cursor: pointer;
|
||||
|
||||
background-image: url("../assets/icons/player/icon24_cc_on.svg");
|
||||
transition: background-image 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.captions_on:hover {
|
||||
background-image: url("../assets/icons/player/icon24_cc_on_active.svg");
|
||||
}
|
||||
|
||||
.leftButtonContainer {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
height: 24px;
|
||||
/* width: calc(50% - 24px); */
|
||||
right: 160px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
overflow: hidden;
|
||||
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
height: 24px;
|
||||
/* width: calc(50% - 24px); */
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.captionsContainer {
|
||||
/* display: none; */
|
||||
position: relative;
|
||||
/* top: -200px; */
|
||||
bottom: 160px;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
|
||||
font-family: InterVariable;
|
||||
font-size: 28px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
padding: 0px 5px;
|
||||
|
||||
width: fit-content;
|
||||
user-select: none;
|
||||
/* transition: bottom 0.2s ease-in-out; */
|
||||
}
|
||||
|
||||
.speedMenu {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
right: 60px;
|
||||
height: calc(55vh);
|
||||
max-height: 368px;
|
||||
|
||||
background-color: #141414;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2E2E2E;
|
||||
scrollbar-width: thin;
|
||||
overflow: auto;
|
||||
|
||||
font-family: InterVariable;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
|
||||
box-shadow: 0px 1.852px 3.148px 0px rgba(0, 0, 0, 0.06), 0px 8.148px 6.519px 0px rgba(0, 0, 0, 0.10), 0px 20px 13px 0px rgba(0, 0, 0, 0.13), 0px 38.519px 25.481px 0px rgba(0, 0, 0, 0.15), 0px 64.815px 46.852px 0px rgba(0, 0, 0, 0.19), 0px 100px 80px 0px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.speedMenuTitle {
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.speedMenuEntry {
|
||||
display: flex;
|
||||
padding: 10px 15px;
|
||||
}
|
||||
|
||||
.speedMenuEntry:hover {
|
||||
cursor: pointer;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.speedMenuSeparator {
|
||||
height: 1px;
|
||||
background: #2E2E2E;
|
||||
margin-top: 3px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.speedMenuEntryEnabled {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 10px;
|
||||
|
||||
background-image: url("../assets/icons/player/icon24_check_thin.svg");
|
||||
background-size: cover;
|
||||
opacity: 0;
|
||||
}
|
|
@ -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,20 @@
|
|||
import { BrowserWindow, ipcMain, IpcMainEvent, nativeImage, Tray, Menu, dialog } from 'electron';
|
||||
import { TcpListenerService } from './TcpListenerService';
|
||||
import { PlayMessage, PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage } from './Packets';
|
||||
import { DiscoveryService } from './DiscoveryService';
|
||||
import { PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage } from 'common/Packets';
|
||||
import { DiscoveryService } from 'common/DiscoveryService';
|
||||
import { TcpListenerService } from 'common/TcpListenerService';
|
||||
import { WebSocketListenerService } from 'common/WebSocketListenerService';
|
||||
import { NetworkService } from 'common/NetworkService';
|
||||
import { Opcode } from 'common/FCastSession';
|
||||
import { Updater } from './Updater';
|
||||
import { WebSocketListenerService } from './WebSocketListenerService';
|
||||
import { Opcode } from './FCastSession';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
import * as url from 'url';
|
||||
import * as log4js from "log4js";
|
||||
import { AddressInfo } from 'ws';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import { ToastIcon } from 'common/components/Toast';
|
||||
const cp = require('child_process');
|
||||
|
||||
export default class Main {
|
||||
export class Main {
|
||||
static shouldOpenMainWindow = true;
|
||||
static startFullscreen = false;
|
||||
static playerWindow: Electron.BrowserWindow;
|
||||
|
@ -25,11 +24,6 @@ export default class Main {
|
|||
static webSocketListenerService: WebSocketListenerService;
|
||||
static discoveryService: DiscoveryService;
|
||||
static tray: Tray;
|
||||
static key: string = null;
|
||||
static cert: string = null;
|
||||
static proxyServer: http.Server;
|
||||
static proxyServerAddress: AddressInfo;
|
||||
static proxiedFiles: Map<string, { url: string, headers: { [key: string]: string } }> = new Map();
|
||||
static logger: log4js.Logger;
|
||||
|
||||
private static toggleMainWindow() {
|
||||
|
@ -80,7 +74,7 @@ export default class Main {
|
|||
}
|
||||
|
||||
private static createTray() {
|
||||
const icon = (process.platform === 'win32') ? path.join(__dirname, 'icon.ico') : path.join(__dirname, 'icon.png');
|
||||
const icon = (process.platform === 'win32') ? path.join(__dirname, 'assets/icons/app/icon.ico') : path.join(__dirname, 'assets/icons/app/icon.png');
|
||||
const trayicon = nativeImage.createFromPath(icon)
|
||||
const tray = new Tray(trayicon.resize({ width: 16 }));
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
|
@ -175,13 +169,13 @@ export default class Main {
|
|||
|
||||
Main.playerWindow.loadFile(path.join(__dirname, 'player/index.html'));
|
||||
Main.playerWindow.on('ready-to-show', async () => {
|
||||
Main.playerWindow?.webContents?.send("play", await Main.proxyPlayIfRequired(message));
|
||||
Main.playerWindow?.webContents?.send("play", await NetworkService.proxyPlayIfRequired(message));
|
||||
});
|
||||
Main.playerWindow.on('closed', () => {
|
||||
Main.playerWindow = null;
|
||||
});
|
||||
} else {
|
||||
Main.playerWindow?.webContents?.send("play", await Main.proxyPlayIfRequired(message));
|
||||
Main.playerWindow?.webContents?.send("play", await NetworkService.proxyPlayIfRequired(message));
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -196,6 +190,10 @@ export default class Main {
|
|||
l.emitter.on("seek", (message) => Main.playerWindow?.webContents?.send("seek", message));
|
||||
l.emitter.on("setvolume", (message) => Main.playerWindow?.webContents?.send("setvolume", message));
|
||||
l.emitter.on("setspeed", (message) => Main.playerWindow?.webContents?.send("setspeed", message));
|
||||
|
||||
l.emitter.on('connect', (message) => Main.mainWindow?.webContents?.send('connect', message));
|
||||
l.emitter.on('disconnect', (message) => Main.mainWindow?.webContents?.send('disconnect', message));
|
||||
l.emitter.on('ping', (message) => Main.mainWindow?.webContents?.send('ping', message));
|
||||
l.start();
|
||||
|
||||
ipcMain.on('send-playback-error', (event: IpcMainEvent, value: PlaybackErrorMessage) => {
|
||||
|
@ -283,116 +281,6 @@ export default class Main {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
private static setupProxyServer(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
Main.logger.info(`Proxy server starting`);
|
||||
|
||||
const port = 0;
|
||||
Main.proxyServer = http.createServer((req, res) => {
|
||||
Main.logger.info(`Request received`);
|
||||
const requestUrl = `http://${req.headers.host}${req.url}`;
|
||||
|
||||
const proxyInfo = Main.proxiedFiles.get(requestUrl);
|
||||
|
||||
if (!proxyInfo) {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const omitHeaders = new Set([
|
||||
'host',
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'te',
|
||||
'trailers',
|
||||
'transfer-encoding',
|
||||
'upgrade'
|
||||
]);
|
||||
|
||||
const filteredHeaders = Object.fromEntries(Object.entries(req.headers)
|
||||
.filter(([key]) => !omitHeaders.has(key.toLowerCase()))
|
||||
.map(([key, value]) => [key, Array.isArray(value) ? value.join(', ') : value]));
|
||||
|
||||
const parsedUrl = url.parse(proxyInfo.url);
|
||||
const options: http.RequestOptions = {
|
||||
... parsedUrl,
|
||||
method: req.method,
|
||||
headers: { ...filteredHeaders, ...proxyInfo.headers }
|
||||
};
|
||||
|
||||
const proxyReq = http.request(options, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res, { end: true });
|
||||
});
|
||||
|
||||
req.pipe(proxyReq, { end: true });
|
||||
proxyReq.on('error', (e) => {
|
||||
Main.logger.error(`Problem with request: ${e.message}`);
|
||||
res.writeHead(500);
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
Main.proxyServer.on('error', e => {
|
||||
reject(e);
|
||||
});
|
||||
Main.proxyServer.listen(port, '127.0.0.1', () => {
|
||||
Main.proxyServerAddress = Main.proxyServer.address() as AddressInfo;
|
||||
Main.logger.info(`Proxy server running at http://127.0.0.1:${Main.proxyServerAddress.port}/`);
|
||||
resolve();
|
||||
});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static streamingMediaTypes = [
|
||||
"application/vnd.apple.mpegurl",
|
||||
"application/x-mpegURL",
|
||||
"application/dash+xml"
|
||||
];
|
||||
|
||||
static async proxyPlayIfRequired(message: PlayMessage): Promise<PlayMessage> {
|
||||
if (message.headers && message.url && !Main.streamingMediaTypes.find(v => v === message.container.toLocaleLowerCase())) {
|
||||
return { ...message, url: await Main.proxyFile(message.url, message.headers) };
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
static async proxyFile(url: string, headers: { [key: string]: string }): Promise<string> {
|
||||
if (!Main.proxyServer) {
|
||||
await Main.setupProxyServer();
|
||||
}
|
||||
|
||||
const proxiedUrl = `http://127.0.0.1:${Main.proxyServerAddress.port}/${uuidv4()}`;
|
||||
Main.logger.info("Proxied url", { proxiedUrl, url, headers });
|
||||
Main.proxiedFiles.set(proxiedUrl, { url: url, headers: headers });
|
||||
return proxiedUrl;
|
||||
}
|
||||
|
||||
static getAllIPv4Addresses() {
|
||||
const interfaces = os.networkInterfaces();
|
||||
const ipv4Addresses: string[] = [];
|
||||
|
||||
for (const interfaceName in interfaces) {
|
||||
const addresses = interfaces[interfaceName];
|
||||
if (!addresses) continue;
|
||||
|
||||
for (const addressInfo of addresses) {
|
||||
if (addressInfo.family === 'IPv4' && !addressInfo.internal) {
|
||||
ipv4Addresses.push(addressInfo.address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ipv4Addresses;
|
||||
}
|
||||
|
||||
static openMainWindow() {
|
||||
if (Main.mainWindow) {
|
||||
Main.mainWindow.focus();
|
||||
|
@ -419,7 +307,7 @@ export default class Main {
|
|||
Main.mainWindow.show();
|
||||
|
||||
Main.mainWindow.on('ready-to-show', () => {
|
||||
Main.mainWindow.webContents.send("device-info", {name: os.hostname(), addresses: Main.getAllIPv4Addresses()});
|
||||
Main.mainWindow.webContents.send("device-info", {name: os.hostname(), addresses: NetworkService.getAllIPv4Addresses()});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -471,3 +359,60 @@ export default class Main {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getComputerName() {
|
||||
switch (process.platform) {
|
||||
case "win32":
|
||||
return process.env.COMPUTERNAME;
|
||||
case "darwin":
|
||||
return cp.execSync("scutil --get ComputerName").toString().trim();
|
||||
case "linux": {
|
||||
let hostname: string;
|
||||
|
||||
// Some distro's don't work with `os.hostname()`, but work with `hostnamectl` and vice versa...
|
||||
try {
|
||||
hostname = os.hostname();
|
||||
}
|
||||
catch (err) {
|
||||
Main.logger.warn('Error fetching hostname, trying different method...');
|
||||
Main.logger.warn(err);
|
||||
|
||||
try {
|
||||
hostname = cp.execSync("hostnamectl hostname").toString().trim();
|
||||
}
|
||||
catch (err2) {
|
||||
Main.logger.warn('Error fetching hostname again, using generic name...');
|
||||
Main.logger.warn(err2);
|
||||
|
||||
hostname = 'linux device';
|
||||
}
|
||||
}
|
||||
|
||||
return hostname;
|
||||
}
|
||||
|
||||
default:
|
||||
return os.hostname();
|
||||
}
|
||||
}
|
||||
|
||||
export async function errorHandler(err: NodeJS.ErrnoException) {
|
||||
Main.logger.error("Application error:", err);
|
||||
Main.mainWindow.webContents.send("toast", { message: err, icon: ToastIcon.ERROR });
|
||||
|
||||
const restartPrompt = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Failed to start',
|
||||
message: 'The application failed to start properly.',
|
||||
buttons: ['Restart', 'Close'],
|
||||
defaultId: 0,
|
||||
cancelId: 1
|
||||
});
|
||||
|
||||
if (restartPrompt.response === 0) {
|
||||
Main.application.relaunch();
|
||||
Main.application.exit(0);
|
||||
} else {
|
||||
Main.application.exit(0);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import 'common/main/Preload';
|
||||
import { toast } from 'common/components/Toast';
|
||||
|
||||
let deviceInfo;
|
||||
ipcRenderer.on("device-info", (_event, value) => {
|
||||
deviceInfo = value;
|
||||
})
|
||||
ipcRenderer.on("toast", (_event, value: any) => {
|
||||
toast(value.message, value.icon, value.duration);
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
updaterProgress: () => ipcRenderer.invoke('updater-progress'),
|
||||
onDeviceInfo: (callback) => ipcRenderer.on("device-info", callback),
|
||||
onUpdateAvailable: (callback) => ipcRenderer.on("update-available", callback),
|
||||
onUpdateAvailable: (callback: any) => ipcRenderer.on("update-available", callback),
|
||||
sendDownloadRequest: () => ipcRenderer.send('send-download-request'),
|
||||
onDownloadComplete: (callback) => ipcRenderer.on("download-complete", callback),
|
||||
onDownloadFailed: (callback) => ipcRenderer.on("download-failed", callback),
|
||||
onDownloadComplete: (callback: any) => ipcRenderer.on("download-complete", callback),
|
||||
onDownloadFailed: (callback: any) => ipcRenderer.on("download-failed", callback),
|
||||
sendRestartRequest: () => ipcRenderer.send('send-restart-request'),
|
||||
getDeviceInfo: () => deviceInfo,
|
||||
});
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import QRCode from 'qrcode';
|
||||
import 'common/main/Renderer';
|
||||
|
||||
export function onQRCodeRendered() {}
|
||||
|
||||
const updateView = document.getElementById("update-view");
|
||||
const updateViewTitle = document.getElementById("update-view-title");
|
||||
|
@ -10,51 +12,6 @@ const progressBar = document.getElementById("progress-bar");
|
|||
const progressBarProgress = document.getElementById("progress-bar-progress");
|
||||
|
||||
let updaterProgressUIUpdateTimer = null;
|
||||
window.electronAPI.onDeviceInfo(renderIPsAndQRCode);
|
||||
|
||||
if(window.electronAPI.getDeviceInfo()) {
|
||||
console.log("device info already present");
|
||||
renderIPsAndQRCode();
|
||||
}
|
||||
|
||||
function renderIPsAndQRCode() {
|
||||
const value = window.electronAPI.getDeviceInfo();
|
||||
console.log("device info", value);
|
||||
|
||||
const ipsElement = document.getElementById('ips');
|
||||
if (ipsElement) {
|
||||
ipsElement.innerHTML = `IPs<br>${value.addresses.join('<br>')}`;
|
||||
}
|
||||
|
||||
const fcastConfig = {
|
||||
name: value.name,
|
||||
addresses: value.addresses,
|
||||
services: [
|
||||
{ port: 46899, type: 0 }, //TCP
|
||||
{ port: 46898, type: 1 }, //WS
|
||||
]
|
||||
};
|
||||
|
||||
const json = JSON.stringify(fcastConfig);
|
||||
let base64 = btoa(json);
|
||||
base64 = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
const url = `fcast://r/${base64}`;
|
||||
console.log("qr", {json, url, base64});
|
||||
|
||||
const qrCodeElement = document.getElementById('qr-code');
|
||||
QRCode.toCanvas(qrCodeElement, url, {
|
||||
margin: 0,
|
||||
width: 256,
|
||||
color: {
|
||||
dark : "#000000",
|
||||
light : "#ffffff",
|
||||
},
|
||||
errorCorrectionLevel : "M",
|
||||
},
|
||||
(e) => {
|
||||
console.log(`Error rendering QR Code: ${e}`)
|
||||
});
|
||||
}
|
||||
|
||||
window.electronAPI.onUpdateAvailable(() => {
|
||||
console.log(`Received UpdateAvailable event`);
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="../../assets/fonts/outfit.css" />
|
||||
<link rel="stylesheet" href="../../assets/fonts/inter.css" />
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
<title>FCast Receiver</title>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="../assets/fonts/outfit.css" />
|
||||
<link rel="stylesheet" href="../assets/fonts/inter.css" />
|
||||
<link rel="stylesheet" href="./common.css" />
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="main-container">
|
||||
<video id="video-player" class="video" autoplay loop>
|
||||
<source src="../../assets/video/background.mp4" type="video/mp4">
|
||||
<source src="../assets/video/background.mp4" type="video/mp4">
|
||||
</video>
|
||||
<div id="ui-container">
|
||||
<div id="overlay">
|
||||
|
@ -21,8 +22,9 @@
|
|||
</div>
|
||||
|
||||
<div id="connection-status">
|
||||
<div id="waiting-for-connection" class="non-selectable">Waiting for a connection</div>
|
||||
<div id="spinner" class="lds-ring"><div></div><div></div><div></div><div></div></div>
|
||||
<div id="connection-status-text" class="non-selectable">Waiting for a connection</div>
|
||||
<div id="connection-spinner" class="lds-ring"><div></div><div></div><div></div><div></div></div>
|
||||
<div id="connection-check"><div id="connection-check-mark"></div></div>
|
||||
</div>
|
||||
|
||||
<div id="update-view" class="card">
|
||||
|
@ -54,6 +56,10 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-notification">
|
||||
<div id="toast-icon"></div>
|
||||
<div id="toast-text"></div>
|
||||
</div>
|
||||
<div id="window-can-be-closed" class="non-selectable">App will continue to run as tray app when the window is closed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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,17 @@
|
|||
import dashjs from 'dashjs';
|
||||
import Hls, { LevelLoadedData } from 'hls.js';
|
||||
import { PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage } from '../Packets';
|
||||
import { Player, PlayerType } from './Player';
|
||||
import { videoElement, PlayerControlEvent, playerCtrlStateUpdate } from 'common/player/Renderer';
|
||||
|
||||
function formatDuration(duration: number) {
|
||||
const totalSeconds = Math.floor(duration);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(totalSeconds % 60);
|
||||
const captionsBaseHeightCollapsed = 75;
|
||||
const captionsBaseHeightExpanded = 160;
|
||||
const captionsLineHeight = 34;
|
||||
|
||||
const paddedMinutes = String(minutes).padStart(2, '0');
|
||||
const paddedSeconds = String(seconds).padStart(2, '0');
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${paddedMinutes}:${paddedSeconds}`;
|
||||
} else {
|
||||
return `${paddedMinutes}:${paddedSeconds}`;
|
||||
}
|
||||
}
|
||||
|
||||
function sendPlaybackUpdate(updateState: number) {
|
||||
const updateMessage = new PlaybackUpdateMessage(Date.now(), player.getCurrentTime(), player.getDuration(), updateState, player.getPlaybackRate());
|
||||
|
||||
if (updateMessage.generationTime > lastPlayerUpdateGenerationTime) {
|
||||
lastPlayerUpdateGenerationTime = updateMessage.generationTime;
|
||||
window.electronAPI.sendPlaybackUpdate(updateMessage);
|
||||
}
|
||||
};
|
||||
|
||||
function onPlayerLoad(value: PlayMessage, currentPlaybackRate?: number, currentVolume?: number) {
|
||||
playerCtrlStateUpdate(PlayerControlEvent.Load);
|
||||
|
||||
// Subtitles break when seeking post stream initialization for the DASH player.
|
||||
// Its currently done on player initialization.
|
||||
if (player.playerType === PlayerType.Hls || player.playerType === PlayerType.Html) {
|
||||
if (value.time) {
|
||||
player.setCurrentTime(value.time);
|
||||
}
|
||||
}
|
||||
|
||||
if (value.speed) {
|
||||
player.setPlaybackRate(value.speed);
|
||||
} else if (currentPlaybackRate) {
|
||||
player.setPlaybackRate(currentPlaybackRate);
|
||||
} else {
|
||||
player.setPlaybackRate(1.0);
|
||||
}
|
||||
playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate);
|
||||
|
||||
if (currentVolume) {
|
||||
volumeChangeHandler(currentVolume);
|
||||
}
|
||||
else {
|
||||
// FCast PlayMessage does not contain volume field and could result in the receiver
|
||||
// getting out-of-sync with the sender on 1st playback.
|
||||
volumeChangeHandler(1.0);
|
||||
window.electronAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: 1.0 });
|
||||
}
|
||||
|
||||
player.play();
|
||||
}
|
||||
|
||||
// HTML elements
|
||||
const videoElement = document.getElementById("videoPlayer") as HTMLVideoElement;
|
||||
const videoCaptions = document.getElementById("videoCaptions") as HTMLDivElement;
|
||||
|
||||
const playerControls = document.getElementById("controls");
|
||||
|
||||
const playerCtrlAction = document.getElementById("action");
|
||||
const playerCtrlVolume = document.getElementById("volume");
|
||||
|
||||
const playerCtrlProgressBar = document.getElementById("progressBar");
|
||||
const playerCtrlProgressBarBuffer = document.getElementById("progressBarBuffer");
|
||||
const playerCtrlProgressBarProgress = document.getElementById("progressBarProgress");
|
||||
const playerCtrlProgressBarPosition = document.getElementById("progressBarPosition");
|
||||
const playerCtrlProgressBarHandle = document.getElementById("progressBarHandle");
|
||||
const PlayerCtrlProgressBarInteractiveArea = document.getElementById("progressBarInteractiveArea");
|
||||
|
||||
const playerCtrlVolumeBar = document.getElementById("volumeBar");
|
||||
const playerCtrlVolumeBarProgress = document.getElementById("volumeBarProgress");
|
||||
const playerCtrlVolumeBarHandle = document.getElementById("volumeBarHandle");
|
||||
const playerCtrlVolumeBarInteractiveArea = document.getElementById("volumeBarInteractiveArea");
|
||||
|
||||
const playerCtrlLiveBadge = document.getElementById("liveBadge");
|
||||
const playerCtrlPosition = document.getElementById("position");
|
||||
const playerCtrlDuration = document.getElementById("duration");
|
||||
|
||||
const playerCtrlCaptions = document.getElementById("captions");
|
||||
const playerCtrlSpeed = document.getElementById("speed");
|
||||
const playerCtrlFullscreen = document.getElementById("fullscreen");
|
||||
playerCtrlFullscreen.onclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); };
|
||||
videoElement.ondblclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); };
|
||||
|
||||
const playerCtrlSpeedMenu = document.getElementById("speedMenu");
|
||||
let playerCtrlSpeedMenuShown = false;
|
||||
export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent): boolean {
|
||||
let handledCase = false;
|
||||
|
||||
|
||||
const playbackRates = ["0.25", "0.50", "0.75", "1.00", "1.25", "1.50", "1.75", "2.00"];
|
||||
const playbackUpdateInterval = 1.0;
|
||||
const livePositionDelta = 5.0;
|
||||
const livePositionWindow = livePositionDelta * 4;
|
||||
let player: Player;
|
||||
let playerPrevTime: number = 0;
|
||||
let lastPlayerUpdateGenerationTime = 0;
|
||||
let isLive = false;
|
||||
let isLivePosition = false;
|
||||
|
||||
|
||||
window.electronAPI.onPlay((_event, value: PlayMessage) => {
|
||||
console.log("Handle play message renderer", JSON.stringify(value));
|
||||
const currentVolume = player ? player.getVolume() : null;
|
||||
const currentPlaybackRate = player ? player.getPlaybackRate() : null;
|
||||
|
||||
playerPrevTime = 0;
|
||||
lastPlayerUpdateGenerationTime = 0;
|
||||
isLive = false;
|
||||
isLivePosition = false;
|
||||
|
||||
if (player) {
|
||||
if (player.getSource() === value.url) {
|
||||
if (value.time) {
|
||||
if (Math.abs(value.time - player.getCurrentTime()) < 5000) {
|
||||
console.warn(`Skipped changing video URL because URL and time is (nearly) unchanged: ${value.url}, ${player.getSource()}, ${formatDuration(value.time)}, ${formatDuration(player.getCurrentTime())}`);
|
||||
} else {
|
||||
console.info(`Skipped changing video URL because URL is the same, but time was changed, seeking instead: ${value.url}, ${player.getSource()}, ${formatDuration(value.time)}, ${formatDuration(player.getCurrentTime())}`);
|
||||
|
||||
player.setCurrentTime(value.time);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
player.destroy();
|
||||
}
|
||||
|
||||
if ((value.url || value.content) && value.container && videoElement) {
|
||||
if (value.container === 'application/dash+xml') {
|
||||
console.log("Loading dash player");
|
||||
const dashPlayer = dashjs.MediaPlayer().create();
|
||||
player = new Player(PlayerType.Dash, dashPlayer);
|
||||
|
||||
dashPlayer.extend("RequestModifier", () => {
|
||||
return {
|
||||
modifyRequestHeader: function (xhr) {
|
||||
if (value.headers) {
|
||||
for (const [key, val] of Object.entries(value.headers)) {
|
||||
xhr.setRequestHeader(key, val);
|
||||
}
|
||||
}
|
||||
|
||||
return xhr;
|
||||
}
|
||||
};
|
||||
}, true);
|
||||
|
||||
// Player event handlers
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PLAYING, () => { sendPlaybackUpdate(1); playerCtrlStateUpdate(PlayerControlEvent.Play); });
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PAUSED, () => { sendPlaybackUpdate(2); playerCtrlStateUpdate(PlayerControlEvent.Pause); });
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ENDED, () => { sendPlaybackUpdate(0) });
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_TIME_UPDATED, () => {
|
||||
playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate);
|
||||
|
||||
if (Math.abs(dashPlayer.time() - playerPrevTime) >= playbackUpdateInterval) {
|
||||
sendPlaybackUpdate(dashPlayer.isPaused() ? 2 : 1);
|
||||
playerPrevTime = dashPlayer.time();
|
||||
}
|
||||
});
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_RATE_CHANGED, () => { sendPlaybackUpdate(dashPlayer.isPaused() ? 2 : 1) });
|
||||
|
||||
// Buffering UI update when paused
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PROGRESS, () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); });
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_VOLUME_CHANGED, () => {
|
||||
const updateVolume = dashPlayer.isMuted() ? 0 : dashPlayer.getVolume();
|
||||
playerCtrlStateUpdate(PlayerControlEvent.VolumeChange);
|
||||
window.electronAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume });
|
||||
});
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.ERROR, (data) => { window.electronAPI.sendPlaybackError({
|
||||
message: `DashJS ERROR: ${JSON.stringify(data)}`
|
||||
})});
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ERROR, (data) => { window.electronAPI.sendPlaybackError({
|
||||
message: `DashJS PLAYBACK_ERROR: ${JSON.stringify(data)}`
|
||||
})});
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.STREAM_INITIALIZED, () => { onPlayerLoad(value, currentPlaybackRate, currentVolume); });
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.CUE_ENTER, (e: any) => {
|
||||
const subtitle = document.createElement("p")
|
||||
subtitle.setAttribute("id", "subtitle-" + e.cueID)
|
||||
|
||||
subtitle.textContent = e.text;
|
||||
videoCaptions.appendChild(subtitle);
|
||||
});
|
||||
|
||||
dashPlayer.on(dashjs.MediaPlayer.events.CUE_EXIT, (e: any) => {
|
||||
document.getElementById("subtitle-" + e.cueID)?.remove();
|
||||
});
|
||||
|
||||
dashPlayer.updateSettings({
|
||||
// debug: {
|
||||
// logLevel: dashjs.LogLevel.LOG_LEVEL_INFO
|
||||
// },
|
||||
streaming: {
|
||||
text: {
|
||||
dispatchForManualRendering: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (value.content) {
|
||||
dashPlayer.initialize(videoElement, `data:${value.container};base64,` + window.btoa(value.content), true, value.time);
|
||||
// dashPlayer.initialize(videoElement, "https://dash.akamaized.net/akamai/test/caption_test/ElephantsDream/elephants_dream_480p_heaac5_1_https.mpd", true);
|
||||
} else {
|
||||
dashPlayer.initialize(videoElement, value.url, true, value.time);
|
||||
}
|
||||
|
||||
} else if ((value.container === 'application/vnd.apple.mpegurl' || value.container === 'application/x-mpegURL') && !videoElement.canPlayType(value.container)) {
|
||||
console.log("Loading hls player");
|
||||
|
||||
const config = {
|
||||
xhrSetup: function (xhr: XMLHttpRequest) {
|
||||
if (value.headers) {
|
||||
for (const [key, val] of Object.entries(value.headers)) {
|
||||
xhr.setRequestHeader(key, val);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const hlsPlayer = new Hls(config);
|
||||
|
||||
hlsPlayer.on(Hls.Events.ERROR, (eventName, data) => {
|
||||
window.electronAPI.sendPlaybackError({
|
||||
message: `HLS player error: ${JSON.stringify(data)}`
|
||||
});
|
||||
});
|
||||
|
||||
hlsPlayer.on(Hls.Events.LEVEL_LOADED, (eventName, level: LevelLoadedData) => {
|
||||
isLive = level.details.live;
|
||||
isLivePosition = isLive ? true : false;
|
||||
});
|
||||
|
||||
player = new Player(PlayerType.Hls, videoElement, hlsPlayer);
|
||||
|
||||
// value.url = "https://devstreaming-cdn.apple.com/videos/streaming/examples/adv_dv_atmos/main.m3u8?ref=developerinsider.co";
|
||||
hlsPlayer.loadSource(value.url);
|
||||
hlsPlayer.attachMedia(videoElement);
|
||||
// hlsPlayer.subtitleDisplay = true;
|
||||
|
||||
} else {
|
||||
console.log("Loading html player");
|
||||
player = new Player(PlayerType.Html, videoElement);
|
||||
|
||||
videoElement.src = value.url;
|
||||
videoElement.load();
|
||||
}
|
||||
|
||||
// Player event handlers
|
||||
if (player.playerType === PlayerType.Hls || player.playerType === PlayerType.Html) {
|
||||
videoElement.onplay = () => { sendPlaybackUpdate(1); playerCtrlStateUpdate(PlayerControlEvent.Play); };
|
||||
videoElement.onpause = () => { sendPlaybackUpdate(2); playerCtrlStateUpdate(PlayerControlEvent.Pause); };
|
||||
videoElement.onended = () => { sendPlaybackUpdate(0) };
|
||||
videoElement.ontimeupdate = () => {
|
||||
playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate);
|
||||
|
||||
if (Math.abs(videoElement.currentTime - playerPrevTime) >= playbackUpdateInterval) {
|
||||
sendPlaybackUpdate(videoElement.paused ? 2 : 1);
|
||||
playerPrevTime = videoElement.currentTime;
|
||||
}
|
||||
};
|
||||
// Buffering UI update when paused
|
||||
videoElement.onprogress = () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); };
|
||||
videoElement.onratechange = () => { sendPlaybackUpdate(videoElement.paused ? 2 : 1) };
|
||||
videoElement.onvolumechange = () => {
|
||||
const updateVolume = videoElement.muted ? 0 : videoElement.volume;
|
||||
playerCtrlStateUpdate(PlayerControlEvent.VolumeChange);
|
||||
window.electronAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume });
|
||||
};
|
||||
|
||||
videoElement.onerror = (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) => {
|
||||
console.error("Player error", {source, lineno, colno, error});
|
||||
};
|
||||
|
||||
videoElement.onloadedmetadata = () => { onPlayerLoad(value, currentPlaybackRate, currentVolume); };
|
||||
}
|
||||
}
|
||||
|
||||
// Sender generated event handlers
|
||||
window.electronAPI.onPause(() => { player.pause(); });
|
||||
window.electronAPI.onResume(() => { player.play(); });
|
||||
window.electronAPI.onSeek((_event, value: SeekMessage) => { player.setCurrentTime(value.time); });
|
||||
window.electronAPI.onSetVolume((_event, value: SetVolumeMessage) => { volumeChangeHandler(value.volume); });
|
||||
window.electronAPI.onSetSpeed((_event, value: SetSpeedMessage) => { player.setPlaybackRate(value.speed); playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate); });
|
||||
});
|
||||
|
||||
let scrubbing = false;
|
||||
let volumeChanging = false;
|
||||
|
||||
enum PlayerControlEvent {
|
||||
Load,
|
||||
Pause,
|
||||
Play,
|
||||
VolumeChange,
|
||||
TimeUpdate,
|
||||
UiFadeOut,
|
||||
UiFadeIn,
|
||||
SetCaptions,
|
||||
ToggleSpeedMenu,
|
||||
SetPlaybackRate,
|
||||
ToggleFullscreen,
|
||||
ExitFullscreen,
|
||||
}
|
||||
|
||||
// UI update handlers
|
||||
function playerCtrlStateUpdate(event: PlayerControlEvent) {
|
||||
switch (event) {
|
||||
case PlayerControlEvent.Load: {
|
||||
playerCtrlProgressBarBuffer.setAttribute("style", "width: 0px");
|
||||
playerCtrlProgressBarProgress.setAttribute("style", "width: 0px");
|
||||
playerCtrlProgressBarHandle.setAttribute("style", `left: ${playerCtrlProgressBar.offsetLeft}px`);
|
||||
|
||||
const volume = Math.round(player.getVolume() * playerCtrlVolumeBar.offsetWidth);
|
||||
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
|
||||
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume + 8}px`);
|
||||
|
||||
if (isLive) {
|
||||
playerCtrlLiveBadge.setAttribute("style", "display: block");
|
||||
playerCtrlPosition.setAttribute("style", "display: none");
|
||||
playerCtrlDuration.setAttribute("style", "display: none");
|
||||
}
|
||||
else {
|
||||
playerCtrlLiveBadge.setAttribute("style", "display: none");
|
||||
playerCtrlPosition.setAttribute("style", "display: block");
|
||||
playerCtrlDuration.setAttribute("style", "display: block");
|
||||
playerCtrlPosition.textContent = formatDuration(player.getCurrentTime());
|
||||
playerCtrlDuration.innerHTML = `/  ${formatDuration(player.getDuration())}`;
|
||||
}
|
||||
|
||||
if (player.isCaptionsSupported()) {
|
||||
playerCtrlCaptions.setAttribute("style", "display: block");
|
||||
videoCaptions.setAttribute("style", "display: block");
|
||||
}
|
||||
else {
|
||||
playerCtrlCaptions.setAttribute("style", "display: none");
|
||||
videoCaptions.setAttribute("style", "display: none");
|
||||
player.enableCaptions(false);
|
||||
}
|
||||
playerCtrlStateUpdate(PlayerControlEvent.SetCaptions);
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.Pause:
|
||||
playerCtrlAction.setAttribute("class", "play");
|
||||
stopUiHideTimer();
|
||||
break;
|
||||
|
||||
case PlayerControlEvent.Play:
|
||||
playerCtrlAction.setAttribute("class", "pause");
|
||||
startUiHideTimer();
|
||||
break;
|
||||
|
||||
case PlayerControlEvent.VolumeChange: {
|
||||
// console.log(`VolumeChange: isMute ${player.isMuted()}, volume: ${player.getVolume()}`);
|
||||
const volume = Math.round(player.getVolume() * playerCtrlVolumeBar.offsetWidth);
|
||||
|
||||
if (player.isMuted()) {
|
||||
playerCtrlVolume.setAttribute("class", "mute");
|
||||
playerCtrlVolumeBarProgress.setAttribute("style", `width: 0px`);
|
||||
playerCtrlVolumeBarHandle.setAttribute("style", `left: 0px`);
|
||||
}
|
||||
else if (player.getVolume() >= 0.5) {
|
||||
playerCtrlVolume.setAttribute("class", "volume_high");
|
||||
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
|
||||
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume}px`);
|
||||
} else {
|
||||
playerCtrlVolume.setAttribute("class", "volume_low");
|
||||
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
|
||||
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume}px`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.TimeUpdate: {
|
||||
// console.log(`TimeUpdate: Position: ${player.getCurrentTime()}, Duration: ${player.getDuration()}`);
|
||||
|
||||
if (isLive) {
|
||||
if (isLivePosition && player.getDuration() - player.getCurrentTime() > livePositionWindow) {
|
||||
isLivePosition = false;
|
||||
playerCtrlLiveBadge.setAttribute("style", `background-color: #595959`);
|
||||
}
|
||||
else if (!isLivePosition && player.getDuration() - player.getCurrentTime() <= livePositionWindow) {
|
||||
isLivePosition = true;
|
||||
playerCtrlLiveBadge.setAttribute("style", `background-color: red`);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLivePosition) {
|
||||
playerCtrlProgressBarProgress.setAttribute("style", `width: ${playerCtrlProgressBar.offsetWidth}px`);
|
||||
playerCtrlProgressBarHandle.setAttribute("style", `left: ${playerCtrlProgressBar.offsetWidth + playerCtrlProgressBar.offsetLeft}px`);
|
||||
}
|
||||
else {
|
||||
const buffer = Math.round((player.getBufferLength() / player.getDuration()) * playerCtrlProgressBar.offsetWidth);
|
||||
const progress = Math.round((player.getCurrentTime() / player.getDuration()) * playerCtrlProgressBar.offsetWidth);
|
||||
const handle = progress + playerCtrlProgressBar.offsetLeft;
|
||||
|
||||
playerCtrlProgressBarBuffer.setAttribute("style", `width: ${buffer}px`);
|
||||
playerCtrlProgressBarProgress.setAttribute("style", `width: ${progress}px`);
|
||||
playerCtrlProgressBarHandle.setAttribute("style", `left: ${handle}px`);
|
||||
|
||||
playerCtrlPosition.textContent = formatDuration(player.getCurrentTime());
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.UiFadeOut:
|
||||
document.body.style.cursor = "none";
|
||||
playerControls.setAttribute("style", "opacity: 0");
|
||||
|
||||
if (player.isCaptionsEnabled()) {
|
||||
videoCaptions.setAttribute("style", "display: block; bottom: 75px;");
|
||||
} else {
|
||||
videoCaptions.setAttribute("style", "display: none; bottom: 75px;");
|
||||
}
|
||||
|
||||
|
||||
break;
|
||||
|
||||
case PlayerControlEvent.UiFadeIn:
|
||||
document.body.style.cursor = "default";
|
||||
playerControls.setAttribute("style", "opacity: 1");
|
||||
|
||||
if (player.isCaptionsEnabled()) {
|
||||
videoCaptions.setAttribute("style", "display: block; bottom: 160px;");
|
||||
} else {
|
||||
videoCaptions.setAttribute("style", "display: none; bottom: 160px;");
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case PlayerControlEvent.SetCaptions:
|
||||
if (player.isCaptionsEnabled()) {
|
||||
playerCtrlCaptions.setAttribute("class", "captions_on");
|
||||
videoCaptions.setAttribute("style", "display: block");
|
||||
} else {
|
||||
playerCtrlCaptions.setAttribute("class", "captions_off");
|
||||
videoCaptions.setAttribute("style", "display: none");
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case PlayerControlEvent.ToggleSpeedMenu: {
|
||||
if (playerCtrlSpeedMenuShown) {
|
||||
playerCtrlSpeedMenu.setAttribute("style", "display: none");
|
||||
} else {
|
||||
playerCtrlSpeedMenu.setAttribute("style", "display: block");
|
||||
}
|
||||
|
||||
playerCtrlSpeedMenuShown = !playerCtrlSpeedMenuShown;
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.SetPlaybackRate: {
|
||||
const rate = player.getPlaybackRate().toFixed(2);
|
||||
const entryElement = document.getElementById(`speedMenuEntry_${rate}_enabled`);
|
||||
|
||||
playbackRates.forEach(r => {
|
||||
const entry = document.getElementById(`speedMenuEntry_${r}_enabled`);
|
||||
entry.setAttribute("style", "opacity: 0");
|
||||
});
|
||||
|
||||
// Ignore updating GUI for custom rates
|
||||
if (entryElement !== null) {
|
||||
entryElement.setAttribute("style", "opacity: 1");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.ToggleFullscreen: {
|
||||
window.electronAPI.toggleFullScreen();
|
||||
|
||||
|
@ -489,269 +23,49 @@ function playerCtrlStateUpdate(event: PlayerControlEvent) {
|
|||
}
|
||||
});
|
||||
|
||||
handledCase = true;
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.ExitFullscreen:
|
||||
window.electronAPI.exitFullScreen();
|
||||
playerCtrlFullscreen.setAttribute("class", "fullscreen_off");
|
||||
|
||||
handledCase = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return handledCase;
|
||||
}
|
||||
|
||||
function scrubbingMouseUIHandler(e: MouseEvent) {
|
||||
const progressBarOffset = e.offsetX - 8;
|
||||
const progressBarWidth = PlayerCtrlProgressBarInteractiveArea.offsetWidth - 16;
|
||||
let time = isLive ? Math.round((1 - (progressBarOffset / progressBarWidth)) * player.getDuration()) : Math.round((progressBarOffset / progressBarWidth) * player.getDuration());
|
||||
time = Math.min(player.getDuration(), Math.max(0.0, time));
|
||||
|
||||
if (scrubbing && isLive && e.buttons === 1) {
|
||||
isLivePosition = false;
|
||||
playerCtrlLiveBadge.setAttribute("style", `background-color: #595959`);
|
||||
}
|
||||
|
||||
const livePrefix = isLive && Math.floor(time) !== 0 ? "-" : "";
|
||||
playerCtrlProgressBarPosition.textContent = isLive ? `${livePrefix}${formatDuration(time)}` : formatDuration(time);
|
||||
|
||||
let offset = e.offsetX - (playerCtrlProgressBarPosition.offsetWidth / 2);
|
||||
offset = Math.min(PlayerCtrlProgressBarInteractiveArea.offsetWidth - (playerCtrlProgressBarPosition.offsetWidth / 1), Math.max(8, offset));
|
||||
playerCtrlProgressBarPosition.setAttribute("style", `display: block; left: ${offset}px`);
|
||||
}
|
||||
|
||||
// Receiver generated event handlers
|
||||
playerCtrlAction.onclick = () => {
|
||||
if (player.isPaused()) {
|
||||
player.play();
|
||||
} else {
|
||||
player.pause();
|
||||
}
|
||||
};
|
||||
|
||||
playerCtrlVolume.onclick = () => { player.setMute(!player.isMuted()); };
|
||||
|
||||
PlayerCtrlProgressBarInteractiveArea.onmousedown = (e: MouseEvent) => { scrubbing = true; scrubbingMouseHandler(e) };
|
||||
PlayerCtrlProgressBarInteractiveArea.onmouseup = () => { scrubbing = false; };
|
||||
PlayerCtrlProgressBarInteractiveArea.onmouseenter = (e: MouseEvent) => {
|
||||
if (e.buttons === 0) {
|
||||
volumeChanging = false;
|
||||
}
|
||||
|
||||
scrubbingMouseUIHandler(e);
|
||||
};
|
||||
PlayerCtrlProgressBarInteractiveArea.onmouseleave = () => { playerCtrlProgressBarPosition.setAttribute("style", "display: none"); };
|
||||
PlayerCtrlProgressBarInteractiveArea.onmousemove = (e: MouseEvent) => { scrubbingMouseHandler(e) };
|
||||
|
||||
function scrubbingMouseHandler(e: MouseEvent) {
|
||||
const progressBarOffset = e.offsetX - 8;
|
||||
const progressBarWidth = PlayerCtrlProgressBarInteractiveArea.offsetWidth - 16;
|
||||
let time = Math.round((progressBarOffset / progressBarWidth) * player.getDuration());
|
||||
time = Math.min(player.getDuration(), Math.max(0.0, time));
|
||||
|
||||
if (scrubbing && e.buttons === 1) {
|
||||
player.setCurrentTime(time);
|
||||
}
|
||||
|
||||
scrubbingMouseUIHandler(e);
|
||||
}
|
||||
|
||||
playerCtrlVolumeBarInteractiveArea.onmousedown = (e: MouseEvent) => { volumeChanging = true; volumeChangeMouseHandler(e) };
|
||||
playerCtrlVolumeBarInteractiveArea.onmouseup = () => { volumeChanging = false; };
|
||||
playerCtrlVolumeBarInteractiveArea.onmouseenter = (e: MouseEvent) => {
|
||||
if (e.buttons === 0) {
|
||||
scrubbing = false;
|
||||
}
|
||||
};
|
||||
playerCtrlVolumeBarInteractiveArea.onmousemove = (e: MouseEvent) => { volumeChangeMouseHandler(e) };
|
||||
playerCtrlVolumeBarInteractiveArea.onwheel = (e: WheelEvent) => {
|
||||
const delta = -e.deltaY;
|
||||
|
||||
if (delta > 0 ) {
|
||||
volumeChangeHandler(Math.min(player.getVolume() + volumeIncrement, 1));
|
||||
} else if (delta < 0) {
|
||||
volumeChangeHandler(Math.max(player.getVolume() - volumeIncrement, 0));
|
||||
}
|
||||
};
|
||||
|
||||
function volumeChangeMouseHandler(e: MouseEvent) {
|
||||
if (volumeChanging && e.buttons === 1) {
|
||||
const volumeBarOffsetX = e.offsetX - 8;
|
||||
const volumeBarWidth = playerCtrlVolumeBarInteractiveArea.offsetWidth - 16;
|
||||
const volume = volumeBarOffsetX / volumeBarWidth;
|
||||
volumeChangeHandler(volume);
|
||||
}
|
||||
}
|
||||
|
||||
function volumeChangeHandler(volume: number) {
|
||||
if (!player.isMuted() && volume <= 0) {
|
||||
player.setMute(true);
|
||||
}
|
||||
else if (player.isMuted() && volume > 0) {
|
||||
player.setMute(false);
|
||||
}
|
||||
|
||||
player.setVolume(volume);
|
||||
}
|
||||
|
||||
playerCtrlLiveBadge.onclick = () => { setLivePosition(); };
|
||||
|
||||
function setLivePosition() {
|
||||
if (!isLivePosition) {
|
||||
isLivePosition = true;
|
||||
|
||||
player.setCurrentTime(player.getDuration() - livePositionDelta);
|
||||
playerCtrlLiveBadge.setAttribute("style", `background-color: red`);
|
||||
|
||||
if (player.isPaused()) {
|
||||
player.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playerCtrlCaptions.onclick = () => { player.enableCaptions(!player.isCaptionsEnabled()); playerCtrlStateUpdate(PlayerControlEvent.SetCaptions); };
|
||||
playerCtrlSpeed.onclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu); };
|
||||
playerCtrlFullscreen.onclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); };
|
||||
|
||||
playbackRates.forEach(r => {
|
||||
const entry = document.getElementById(`speedMenuEntry_${r}`);
|
||||
entry.onclick = () => {
|
||||
player.setPlaybackRate(parseFloat(r));
|
||||
playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate);
|
||||
playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu);
|
||||
};
|
||||
});
|
||||
|
||||
videoElement.onclick = () => {
|
||||
if (!playerCtrlSpeedMenuShown) {
|
||||
if (player.isPaused()) {
|
||||
player.play();
|
||||
} else {
|
||||
player.pause();
|
||||
}
|
||||
}
|
||||
};
|
||||
videoElement.ondblclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); };
|
||||
|
||||
// Component hiding
|
||||
let uiHideTimer = null;
|
||||
let uiVisible = true;
|
||||
|
||||
function startUiHideTimer() {
|
||||
if (uiHideTimer === null) {
|
||||
uiHideTimer = window.setTimeout(() => {
|
||||
uiHideTimer = null;
|
||||
uiVisible = false;
|
||||
playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function stopUiHideTimer() {
|
||||
if (uiHideTimer) {
|
||||
window.clearTimeout(uiHideTimer);
|
||||
uiHideTimer = null;
|
||||
}
|
||||
|
||||
if (!uiVisible) {
|
||||
uiVisible = true;
|
||||
playerCtrlStateUpdate(PlayerControlEvent.UiFadeIn);
|
||||
}
|
||||
}
|
||||
|
||||
document.onmouseout = () => {
|
||||
if (uiHideTimer) {
|
||||
window.clearTimeout(uiHideTimer);
|
||||
uiHideTimer = null;
|
||||
}
|
||||
|
||||
uiVisible = false;
|
||||
playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut);
|
||||
}
|
||||
|
||||
document.onmousemove = () => {
|
||||
stopUiHideTimer();
|
||||
|
||||
if (player && !player.isPaused()) {
|
||||
startUiHideTimer();
|
||||
}
|
||||
};
|
||||
|
||||
window.onresize = () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); };
|
||||
|
||||
// Listener for hiding speed menu when clicking outside element
|
||||
document.addEventListener('click', (event: MouseEvent) => {
|
||||
const node = event.target as Node;
|
||||
if (playerCtrlSpeedMenuShown && !playerCtrlSpeed.contains(node) && !playerCtrlSpeedMenu.contains(node)){
|
||||
playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu);
|
||||
}
|
||||
});
|
||||
|
||||
// Add the keydown event listener to the document
|
||||
const skipInterval = 10;
|
||||
const volumeIncrement = 0.1;
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
// console.log("KeyDown", event);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function targetKeyDownEventListener(event: any): boolean {
|
||||
let handledCase = false;
|
||||
|
||||
switch (event.code) {
|
||||
case 'KeyF':
|
||||
case 'F11':
|
||||
playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen);
|
||||
event.preventDefault();
|
||||
handledCase = true;
|
||||
break;
|
||||
case 'Escape':
|
||||
playerCtrlStateUpdate(PlayerControlEvent.ExitFullscreen);
|
||||
event.preventDefault();
|
||||
handledCase = true;
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
// Skip back
|
||||
player.setCurrentTime(Math.max(player.getCurrentTime() - skipInterval, 0));
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
// Skip forward
|
||||
if (!isLivePosition) {
|
||||
player.setCurrentTime(Math.min(player.getCurrentTime() + skipInterval, player.getDuration()));
|
||||
}
|
||||
event.preventDefault();
|
||||
break;
|
||||
case "Home":
|
||||
player.setCurrentTime(0);
|
||||
event.preventDefault();
|
||||
break;
|
||||
case "End":
|
||||
if (isLive) {
|
||||
setLivePosition();
|
||||
}
|
||||
else {
|
||||
player.setCurrentTime(player.getDuration());
|
||||
}
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'KeyK':
|
||||
case 'Space':
|
||||
case 'Enter':
|
||||
// Pause/Continue
|
||||
if (player.isPaused()) {
|
||||
player.play();
|
||||
} else {
|
||||
player.pause();
|
||||
}
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'KeyM':
|
||||
// Mute toggle
|
||||
player.setMute(!player.isMuted());
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
// Volume up
|
||||
volumeChangeHandler(Math.min(player.getVolume() + volumeIncrement, 1));
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
// Volume down
|
||||
volumeChangeHandler(Math.max(player.getVolume() - volumeIncrement, 0));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return handledCase
|
||||
};
|
||||
|
||||
export {
|
||||
captionsBaseHeightCollapsed,
|
||||
captionsBaseHeightExpanded,
|
||||
captionsLineHeight,
|
||||
}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="../../assets/fonts/inter.css" />
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
<title>FCast Receiver</title>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="../assets/fonts/inter.css" />
|
||||
<link rel="stylesheet" href="./common.css" />
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<video id="videoPlayer" autoplay preload="auto"></video>
|
||||
|
@ -22,9 +23,9 @@
|
|||
</div>
|
||||
|
||||
<div class="leftButtonContainer">
|
||||
<div id="action" class="play"></div>
|
||||
<div id="action" class="play iconSize"></div>
|
||||
|
||||
<div id="volume" class="volume_high"></div>
|
||||
<div id="volume" class="volume_high iconSize"></div>
|
||||
<div class="volumeContainer">
|
||||
<div id="volumeBar" ref="volumeBar" class="volumeBar" ></div>
|
||||
<div id="volumeBarProgress" class="volumeBarProgress" ></div>
|
||||
|
@ -35,14 +36,15 @@
|
|||
<div class="positionContainer">
|
||||
<div id="liveBadge" class="liveBadge" style="display: none">LIVE</div>
|
||||
<div id="position" class="position">00:00</div>
|
||||
<div id="duration" class="duration">/  00:00</div>
|
||||
<div id="durationSeparator" class="duration">/  </div>
|
||||
<div id="duration" class="duration">00:00</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="buttonContainer">
|
||||
<div id="fullscreen" class="fullscreen_on"></div>
|
||||
<div id="speed" class="speed"></div>
|
||||
<div id="captions" class="captions_off"></div>
|
||||
<div id="fullscreen" class="fullscreen_on iconSize"></div>
|
||||
<div id="speed" class="speed iconSize"></div>
|
||||
<div id="captions" class="captions_off iconSize"></div>
|
||||
</div>
|
||||
|
||||
<div id="speedMenu" class="speedMenu" style="display: none">
|
||||
|
|
|
@ -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,29 +1,65 @@
|
|||
const webpack = require('webpack');
|
||||
const path = require('path');
|
||||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||
// const buildMode = 'production';
|
||||
const buildMode = 'development';
|
||||
|
||||
const TARGET = 'electron';
|
||||
// const TARGET = 'webOS';
|
||||
// const TARGET = 'tizenOS';
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
mode: 'development',
|
||||
mode: buildMode,
|
||||
entry: './src/App.ts',
|
||||
target: 'electron-main',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
include: /src/,
|
||||
include: [path.resolve(__dirname, '../common/web'), path.resolve(__dirname, 'src')],
|
||||
use: [{ loader: 'ts-loader' }]
|
||||
}
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'src': path.resolve(__dirname, 'src'),
|
||||
'modules': path.resolve(__dirname, 'node_modules'),
|
||||
'common': path.resolve(__dirname, '../common/web'),
|
||||
},
|
||||
extensions: ['.tsx', '.ts', '.js'],
|
||||
},
|
||||
output: {
|
||||
filename: 'bundle.js',
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
},
|
||||
plugins: [
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
// Common assets
|
||||
{
|
||||
from: '../common/assets/**',
|
||||
to: './[path][name][ext]',
|
||||
context: path.resolve(__dirname, '..', 'common'),
|
||||
globOptions: { ignore: ['**/*.txt'] }
|
||||
},
|
||||
// Target assets
|
||||
{
|
||||
from: '**',
|
||||
to: './assets/[path][name][ext]',
|
||||
context: path.resolve(__dirname, 'assets'),
|
||||
globOptions: { ignore: [] }
|
||||
}
|
||||
],
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
TARGET: JSON.stringify(TARGET)
|
||||
})
|
||||
]
|
||||
},
|
||||
{
|
||||
mode: 'development',
|
||||
mode: buildMode,
|
||||
entry: {
|
||||
preload: './src/main/Preload.ts',
|
||||
renderer: './src/main/Renderer.ts',
|
||||
|
@ -33,21 +69,44 @@ module.exports = [
|
|||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
include: /src/,
|
||||
include: [path.resolve(__dirname, '../common/web'), path.resolve(__dirname, 'src')],
|
||||
use: [{ loader: 'ts-loader' }]
|
||||
}
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'src': path.resolve(__dirname, 'src'),
|
||||
'modules': path.resolve(__dirname, 'node_modules'),
|
||||
'common': path.resolve(__dirname, '../common/web'),
|
||||
},
|
||||
extensions: ['.tsx', '.ts', '.js'],
|
||||
},
|
||||
output: {
|
||||
filename: '[name].js',
|
||||
path: path.resolve(__dirname, 'dist/main'),
|
||||
},
|
||||
plugins: [
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: '../common/web/main/common.css',
|
||||
to: '[name][ext]',
|
||||
},
|
||||
{
|
||||
from: './src/main/*',
|
||||
to: '[name][ext]',
|
||||
globOptions: { ignore: ['**/*.ts'] }
|
||||
}
|
||||
],
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
TARGET: JSON.stringify(TARGET)
|
||||
})
|
||||
]
|
||||
},
|
||||
{
|
||||
mode: 'development',
|
||||
mode: buildMode,
|
||||
entry: {
|
||||
preload: './src/player/Preload.ts',
|
||||
renderer: './src/player/Renderer.ts',
|
||||
|
@ -57,17 +116,40 @@ module.exports = [
|
|||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
include: /src/,
|
||||
include: [path.resolve(__dirname, '../common/web'), path.resolve(__dirname, 'src')],
|
||||
use: [{ loader: 'ts-loader' }]
|
||||
}
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'src': path.resolve(__dirname, 'src'),
|
||||
'modules': path.resolve(__dirname, 'node_modules'),
|
||||
'common': path.resolve(__dirname, '../common/web'),
|
||||
},
|
||||
extensions: ['.tsx', '.ts', '.js'],
|
||||
},
|
||||
output: {
|
||||
filename: '[name].js',
|
||||
path: path.resolve(__dirname, 'dist/player'),
|
||||
},
|
||||
plugins: [
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: '../common/web/player/common.css',
|
||||
to: '[name][ext]',
|
||||
},
|
||||
{
|
||||
from: './src/player/*',
|
||||
to: '[name][ext]',
|
||||
globOptions: { ignore: ['**/*.ts'] }
|
||||
}
|
||||
],
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
TARGET: JSON.stringify(TARGET)
|
||||
})
|
||||
]
|
||||
}
|
||||
];
|
26
receivers/webos/.gitlab-ci.yml
Normal file
|
@ -0,0 +1,26 @@
|
|||
|
||||
buildWebOS:
|
||||
stage: buildWebOSReceiver
|
||||
image: node:22.11.0-bookworm
|
||||
tags:
|
||||
- fcast-instance-runner
|
||||
before_script:
|
||||
- cd receivers/webos
|
||||
- npm install -g @webos-tools/cli
|
||||
script:
|
||||
- cd fcast-receiver
|
||||
- npm install
|
||||
- npm run build
|
||||
- cd ../fcast-receiver-service
|
||||
- npm install
|
||||
- npm run build
|
||||
- cd ../
|
||||
- ares-package fcast-receiver/dist/ fcast-receiver-service/dist/ --no-minify
|
||||
artifacts:
|
||||
untracked: false
|
||||
when: on_success
|
||||
access: all
|
||||
expire_in: "30 days"
|
||||
paths:
|
||||
- receivers/webos/*.ipk
|
||||
when: manual
|
33
receivers/webos/README.md
Normal file
|
@ -0,0 +1,33 @@
|
|||
# FCast WebOS Receiver
|
||||
|
||||
The FCast WebOS Receiver is split into two separate projects `fcast-receiver` for frontend UI and `fcast-receiver-service` for the background network service. The WebOS receiver is supported running on TV devices from WebOS TV 5.0 and later.
|
||||
|
||||
The TV receiver player is a simplified player compared to the Electron receiver due to functionality being redundant when using a TV remote control or due to platform limitations (https://gitlab.futo.org/videostreaming/fcast/-/issues/21).
|
||||
|
||||
# How to build
|
||||
|
||||
From `receivers/webos` directory:
|
||||
|
||||
## Prerequisites
|
||||
```sh
|
||||
npm install -g @webos-tools/cli
|
||||
cd fcast-receiver
|
||||
npm install
|
||||
cd ../fcast-receiver-service
|
||||
npm install
|
||||
cd ../
|
||||
```
|
||||
|
||||
## Build
|
||||
```sh
|
||||
cd fcast-receiver
|
||||
npm run build
|
||||
cd ../fcast-receiver-service
|
||||
npm run build
|
||||
cd ../
|
||||
```
|
||||
|
||||
## Packaging
|
||||
```sh
|
||||
ares-package fcast-receiver/dist/ fcast-receiver-service/dist/ --no-minify
|
||||
```
|
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"
|
||||
}
|
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"],
|
||||
};
|
7161
receivers/webos/fcast-receiver-service/package-lock.json
generated
Normal file
42
receivers/webos/fcast-receiver-service/package.json
Normal file
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"name": "com.futo.fcast.receiver.service",
|
||||
"version": "1.0.0",
|
||||
"description": "FCast network service",
|
||||
"author": "FUTO",
|
||||
"license": "MIT",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"build": "rm -rf dist/ && webpack --config ./webpack.config.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.10.0",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/mdns": "^0.0.38",
|
||||
"@types/node-forge": "^1.3.10",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/webos-service": "^0.4.6",
|
||||
"@types/webostvjs": "^1.2.6",
|
||||
"@types/ws": "^8.5.10",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"eslint": "^9.10.0",
|
||||
"globals": "^15.9.0",
|
||||
"jest": "^29.7.0",
|
||||
"mdns-js": "github:mdns-js/node-mdns-js",
|
||||
"patch-package": "^8.0.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.4.2",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript-eslint": "^8.4.0",
|
||||
"webpack": "^5.75.0",
|
||||
"webpack-cli": "^5.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"http": "^0.0.1-security",
|
||||
"log4js": "^6.9.1",
|
||||
"url": "^0.11.3",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.14.2"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
215
receivers/webos/fcast-receiver-service/src/Main.ts
Normal file
|
@ -0,0 +1,215 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// No node module for this package, only exists in webOS environment
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const Service = __non_webpack_require__('webos-service');
|
||||
// const Service = require('webos-service');
|
||||
|
||||
import { PlayMessage, PlaybackErrorMessage, PlaybackUpdateMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage, VolumeUpdateMessage } from 'common/Packets';
|
||||
import { DiscoveryService } from 'common/DiscoveryService';
|
||||
import { TcpListenerService } from 'common/TcpListenerService';
|
||||
import { WebSocketListenerService } from 'common/WebSocketListenerService';
|
||||
import { NetworkService } from 'common/NetworkService';
|
||||
import { Opcode } from 'common/FCastSession';
|
||||
import * as os from 'os';
|
||||
import * as log4js from "log4js";
|
||||
import { EventEmitter } from 'events';
|
||||
import { ToastIcon } from 'common/components/Toast';
|
||||
|
||||
export class Main {
|
||||
static tcpListenerService: TcpListenerService;
|
||||
static webSocketListenerService: WebSocketListenerService;
|
||||
static discoveryService: DiscoveryService;
|
||||
static logger: log4js.Logger;
|
||||
static emitter: EventEmitter;
|
||||
|
||||
static {
|
||||
try {
|
||||
log4js.configure({
|
||||
appenders: {
|
||||
console: { type: 'console' },
|
||||
},
|
||||
categories: {
|
||||
default: { appenders: ['console'], level: 'info' },
|
||||
},
|
||||
});
|
||||
Main.logger = log4js.getLogger();
|
||||
Main.logger.info(`OS: ${process.platform} ${process.arch}`);
|
||||
|
||||
const serviceId = 'com.futo.fcast.receiver.service';
|
||||
const service = new Service(serviceId);
|
||||
|
||||
// Service will timeout and casting will disconnect if not forced to be kept alive
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
let keepAlive;
|
||||
service.activityManager.create("keepAlive", function(activity) {
|
||||
keepAlive = activity;
|
||||
});
|
||||
|
||||
// TODO: Fix issue of emitting events to non-existent subscribers after multiple page changes
|
||||
const voidCb = (message: any) => { message.respond({ returnValue: true, value: {} }); };
|
||||
const objectCb = (message: any, value: any) => { message.respond({ returnValue: true, value: value }); };
|
||||
|
||||
registerService(service, 'toast', (message: any) => { return objectCb.bind(this, message) });
|
||||
|
||||
service.register("getDeviceInfo", (message: any) => {
|
||||
Main.logger.info("In getDeviceInfo callback");
|
||||
|
||||
message.respond({
|
||||
returnValue: true,
|
||||
value: { name: os.hostname(), addresses: NetworkService.getAllIPv4Addresses() }
|
||||
});
|
||||
});
|
||||
|
||||
registerService(service, 'connect', (message: any) => { return objectCb.bind(this, message) });
|
||||
registerService(service, 'disconnect', (message: any) => { return objectCb.bind(this, message) });
|
||||
registerService(service, 'ping', (message: any) => { return objectCb.bind(this, message) });
|
||||
|
||||
Main.discoveryService = new DiscoveryService();
|
||||
Main.discoveryService.start();
|
||||
|
||||
Main.tcpListenerService = new TcpListenerService();
|
||||
Main.webSocketListenerService = new WebSocketListenerService();
|
||||
|
||||
Main.emitter = new EventEmitter();
|
||||
let playData: PlayMessage = null;
|
||||
|
||||
let playClosureCb = null;
|
||||
const playCb = (message: any, playMessage: PlayMessage) => {
|
||||
playData = playMessage;
|
||||
message.respond({ returnValue: true, value: { playData: playData } });
|
||||
};
|
||||
|
||||
let stopClosureCb: any = null;
|
||||
const seekCb = (message: any, seekMessage: SeekMessage) => { message.respond({ returnValue: true, value: seekMessage }); };
|
||||
const setVolumeCb = (message: any, volumeMessage: SetVolumeMessage) => { message.respond({ returnValue: true, value: volumeMessage }); };
|
||||
const setSpeedCb = (message: any, speedMessage: SetSpeedMessage) => { message.respond({ returnValue: true, value: speedMessage }); };
|
||||
|
||||
// Note: When logging the `message` object, do NOT use JSON.stringify, you can log messages directly. Seems to be a circular reference causing errors...
|
||||
service.register('play', (message: any) => {
|
||||
if (message.isSubscription) {
|
||||
playClosureCb = playCb.bind(this, message);
|
||||
Main.emitter.on('play', playClosureCb);
|
||||
}
|
||||
|
||||
message.respond({ returnValue: true, value: { subscribed: true, playData: playData }});
|
||||
},
|
||||
(message: any) => {
|
||||
Main.logger.info('Canceled play service subscriber');
|
||||
Main.emitter.off('play', playClosureCb);
|
||||
message.respond({ returnValue: true, value: message.payload });
|
||||
});
|
||||
|
||||
registerService(service, 'pause', (message: any) => { return voidCb.bind(this, message) });
|
||||
registerService(service, 'resume', (message: any) => { return voidCb.bind(this, message) });
|
||||
|
||||
service.register('stop', (message: any) => {
|
||||
playData = null;
|
||||
|
||||
if (message.isSubscription) {
|
||||
stopClosureCb = voidCb.bind(this, message);
|
||||
Main.emitter.on('stop', stopClosureCb);
|
||||
}
|
||||
|
||||
message.respond({ returnValue: true, value: { subscribed: true }});
|
||||
},
|
||||
(message: any) => {
|
||||
Main.logger.info('Canceled stop service subscriber');
|
||||
Main.emitter.off('stop', stopClosureCb);
|
||||
message.respond({ returnValue: true, value: message.payload });
|
||||
});
|
||||
|
||||
registerService(service, 'seek', (message: any) => { return seekCb.bind(this, message) });
|
||||
registerService(service, 'setvolume', (message: any) => { return setVolumeCb.bind(this, message) });
|
||||
registerService(service, 'setspeed', (message: any) => { return setSpeedCb.bind(this, message) });
|
||||
|
||||
const listeners = [Main.tcpListenerService, Main.webSocketListenerService];
|
||||
listeners.forEach(l => {
|
||||
l.emitter.on("play", async (message) => {
|
||||
await NetworkService.proxyPlayIfRequired(message);
|
||||
Main.emitter.emit('play', message);
|
||||
|
||||
const appId = 'com.futo.fcast.receiver';
|
||||
service.call("luna://com.webos.applicationManager/launch", {
|
||||
'id': appId,
|
||||
'params': { timestamp: Date.now(), playData: message }
|
||||
}, (response: any) => {
|
||||
Main.logger.info(`Launch response: ${JSON.stringify(response)}`);
|
||||
Main.logger.info(`Relaunching FCast Receiver with args: ${JSON.stringify(message)}`);
|
||||
});
|
||||
});
|
||||
l.emitter.on("pause", () => Main.emitter.emit('pause'));
|
||||
l.emitter.on("resume", () => Main.emitter.emit('resume'));
|
||||
l.emitter.on("stop", () => Main.emitter.emit('stop'));
|
||||
l.emitter.on("seek", (message) => Main.emitter.emit('seek', message));
|
||||
l.emitter.on("setvolume", (message) => Main.emitter.emit('setvolume', message));
|
||||
l.emitter.on("setspeed", (message) => Main.emitter.emit('setspeed', message));
|
||||
|
||||
l.emitter.on('connect', (message) => Main.emitter.emit('connect', message));
|
||||
l.emitter.on('disconnect', (message) => Main.emitter.emit('disconnect', message));
|
||||
l.emitter.on('ping', (message) => Main.emitter.emit('ping', message));
|
||||
l.start();
|
||||
});
|
||||
|
||||
service.register("send_playback_error", (message: any) => {
|
||||
listeners.forEach(l => {
|
||||
const value: PlaybackErrorMessage = message.payload.error;
|
||||
l.send(Opcode.PlaybackError, value);
|
||||
});
|
||||
|
||||
message.respond({ returnValue: true, value: { success: true } });
|
||||
});
|
||||
|
||||
service.register("send_playback_update", (message: any) => {
|
||||
// Main.logger.info("In send_playback_update callback");
|
||||
|
||||
listeners.forEach(l => {
|
||||
const value: PlaybackUpdateMessage = message.payload.update;
|
||||
l.send(Opcode.PlaybackUpdate, value);
|
||||
});
|
||||
|
||||
message.respond({ returnValue: true, value: { success: true } });
|
||||
});
|
||||
|
||||
service.register("send_volume_update", (message: any) => {
|
||||
listeners.forEach(l => {
|
||||
const value: VolumeUpdateMessage = message.payload.update;
|
||||
l.send(Opcode.VolumeUpdate, value);
|
||||
});
|
||||
|
||||
message.respond({ returnValue: true, value: { success: true } });
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
Main.logger.error("Error initializing service:", err);
|
||||
Main.emitter.emit('toast', { message: `Error initializing service: ${err}`, icon: ToastIcon.ERROR });
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export function getComputerName() {
|
||||
return os.hostname();
|
||||
}
|
||||
|
||||
export async function errorHandler(err: NodeJS.ErrnoException) {
|
||||
Main.logger.error("Application error:", err);
|
||||
Main.emitter.emit('toast', { message: err, icon: ToastIcon.ERROR });
|
||||
}
|
||||
|
||||
function registerService(service: Service, method: string, callback: (message: any) => any) {
|
||||
let callbackRef = null;
|
||||
service.register(method, (message: any) => {
|
||||
if (message.isSubscription) {
|
||||
callbackRef = callback(message);
|
||||
Main.emitter.on(method, callbackRef);
|
||||
}
|
||||
|
||||
message.respond({ returnValue: true, value: { subscribed: true }});
|
||||
},
|
||||
(message: any) => {
|
||||
Main.logger.info(`Canceled ${method} service subscriber`);
|
||||
Main.emitter.off(method, callbackRef);
|
||||
message.respond({ returnValue: true, value: message.payload });
|
||||
});
|
||||
}
|
21
receivers/webos/fcast-receiver-service/tsconfig.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2015",
|
||||
"module": "ES2015",
|
||||
"moduleResolution": "node10",
|
||||
"sourceMap": false,
|
||||
"emitDecoratorMetadata": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"removeComments": false,
|
||||
"noImplicitAny": false,
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"src/*": ["./src/*"],
|
||||
"modules/*": ["./node_modules/*"],
|
||||
"common/*": ["../../common/web/*"],
|
||||
}
|
||||
},
|
||||
"exclude": [ "node_modules", "test" ]
|
||||
}
|