diff --git a/.gitignore b/.gitignore index 5ca0859..264d223 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ .vscode node_modules/ -.wrangler/ \ No newline at end of file +.wrangler/ + +receivers/webos/*.ipk diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e4651cb..b4468ad 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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' \ No newline at end of file + - local: 'receivers/electron/.gitlab-ci.yml' + - local: 'receivers/webos/.gitlab-ci.yml' diff --git a/receivers/common/assets/fonts/Inter/Inter-VariableFont_slnt-wght.ttf b/receivers/common/assets/fonts/Inter/Inter-VariableFont_slnt-wght.ttf new file mode 100644 index 0000000..ec3164e Binary files /dev/null and b/receivers/common/assets/fonts/Inter/Inter-VariableFont_slnt-wght.ttf differ diff --git a/receivers/electron/assets/fonts/InterVariable.woff2 b/receivers/common/assets/fonts/Inter/InterVariable.woff2 similarity index 100% rename from receivers/electron/assets/fonts/InterVariable.woff2 rename to receivers/common/assets/fonts/Inter/InterVariable.woff2 diff --git a/receivers/common/assets/fonts/Inter/OFL.txt b/receivers/common/assets/fonts/Inter/OFL.txt new file mode 100644 index 0000000..ad21484 --- /dev/null +++ b/receivers/common/assets/fonts/Inter/OFL.txt @@ -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. diff --git a/receivers/common/assets/fonts/Inter/README.txt b/receivers/common/assets/fonts/Inter/README.txt new file mode 100644 index 0000000..3078f19 --- /dev/null +++ b/receivers/common/assets/fonts/Inter/README.txt @@ -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. diff --git a/receivers/common/assets/fonts/Inter/static/Inter-Black.ttf b/receivers/common/assets/fonts/Inter/static/Inter-Black.ttf new file mode 100644 index 0000000..5aecf7d Binary files /dev/null and b/receivers/common/assets/fonts/Inter/static/Inter-Black.ttf differ diff --git a/receivers/common/assets/fonts/Inter/static/Inter-Bold.ttf b/receivers/common/assets/fonts/Inter/static/Inter-Bold.ttf new file mode 100644 index 0000000..8e82c70 Binary files /dev/null and b/receivers/common/assets/fonts/Inter/static/Inter-Bold.ttf differ diff --git a/receivers/common/assets/fonts/Inter/static/Inter-ExtraBold.ttf b/receivers/common/assets/fonts/Inter/static/Inter-ExtraBold.ttf new file mode 100644 index 0000000..cb4b821 Binary files /dev/null and b/receivers/common/assets/fonts/Inter/static/Inter-ExtraBold.ttf differ diff --git a/receivers/common/assets/fonts/Inter/static/Inter-ExtraLight.ttf b/receivers/common/assets/fonts/Inter/static/Inter-ExtraLight.ttf new file mode 100644 index 0000000..64aee30 Binary files /dev/null and b/receivers/common/assets/fonts/Inter/static/Inter-ExtraLight.ttf differ diff --git a/receivers/common/assets/fonts/Inter/static/Inter-Light.ttf b/receivers/common/assets/fonts/Inter/static/Inter-Light.ttf new file mode 100644 index 0000000..9e265d8 Binary files /dev/null and b/receivers/common/assets/fonts/Inter/static/Inter-Light.ttf differ diff --git a/receivers/common/assets/fonts/Inter/static/Inter-Medium.ttf b/receivers/common/assets/fonts/Inter/static/Inter-Medium.ttf new file mode 100644 index 0000000..b53fb1c Binary files /dev/null and b/receivers/common/assets/fonts/Inter/static/Inter-Medium.ttf differ diff --git a/receivers/common/assets/fonts/Inter/static/Inter-Regular.ttf b/receivers/common/assets/fonts/Inter/static/Inter-Regular.ttf new file mode 100644 index 0000000..8d4eebf Binary files /dev/null and b/receivers/common/assets/fonts/Inter/static/Inter-Regular.ttf differ diff --git a/receivers/common/assets/fonts/Inter/static/Inter-SemiBold.ttf b/receivers/common/assets/fonts/Inter/static/Inter-SemiBold.ttf new file mode 100644 index 0000000..c6aeeb1 Binary files /dev/null and b/receivers/common/assets/fonts/Inter/static/Inter-SemiBold.ttf differ diff --git a/receivers/common/assets/fonts/Inter/static/Inter-Thin.ttf b/receivers/common/assets/fonts/Inter/static/Inter-Thin.ttf new file mode 100644 index 0000000..7aed55d Binary files /dev/null and b/receivers/common/assets/fonts/Inter/static/Inter-Thin.ttf differ diff --git a/receivers/common/assets/fonts/Outfit/OFL.txt b/receivers/common/assets/fonts/Outfit/OFL.txt new file mode 100644 index 0000000..42c9469 --- /dev/null +++ b/receivers/common/assets/fonts/Outfit/OFL.txt @@ -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. diff --git a/receivers/electron/assets/fonts/Outfit-VariableFont_wght.ttf b/receivers/common/assets/fonts/Outfit/Outfit-VariableFont_wght.ttf similarity index 100% rename from receivers/electron/assets/fonts/Outfit-VariableFont_wght.ttf rename to receivers/common/assets/fonts/Outfit/Outfit-VariableFont_wght.ttf diff --git a/receivers/common/assets/fonts/Outfit/README.txt b/receivers/common/assets/fonts/Outfit/README.txt new file mode 100644 index 0000000..702665a --- /dev/null +++ b/receivers/common/assets/fonts/Outfit/README.txt @@ -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. diff --git a/receivers/common/assets/fonts/Outfit/static/Outfit-Black.ttf b/receivers/common/assets/fonts/Outfit/static/Outfit-Black.ttf new file mode 100644 index 0000000..487752b Binary files /dev/null and b/receivers/common/assets/fonts/Outfit/static/Outfit-Black.ttf differ diff --git a/receivers/common/assets/fonts/Outfit/static/Outfit-Bold.ttf b/receivers/common/assets/fonts/Outfit/static/Outfit-Bold.ttf new file mode 100644 index 0000000..0a081bc Binary files /dev/null and b/receivers/common/assets/fonts/Outfit/static/Outfit-Bold.ttf differ diff --git a/receivers/common/assets/fonts/Outfit/static/Outfit-ExtraBold.ttf b/receivers/common/assets/fonts/Outfit/static/Outfit-ExtraBold.ttf new file mode 100644 index 0000000..0977ed5 Binary files /dev/null and b/receivers/common/assets/fonts/Outfit/static/Outfit-ExtraBold.ttf differ diff --git a/receivers/common/assets/fonts/Outfit/static/Outfit-ExtraLight.ttf b/receivers/common/assets/fonts/Outfit/static/Outfit-ExtraLight.ttf new file mode 100644 index 0000000..938fe31 Binary files /dev/null and b/receivers/common/assets/fonts/Outfit/static/Outfit-ExtraLight.ttf differ diff --git a/receivers/common/assets/fonts/Outfit/static/Outfit-Light.ttf b/receivers/common/assets/fonts/Outfit/static/Outfit-Light.ttf new file mode 100644 index 0000000..c18b0c1 Binary files /dev/null and b/receivers/common/assets/fonts/Outfit/static/Outfit-Light.ttf differ diff --git a/receivers/common/assets/fonts/Outfit/static/Outfit-Medium.ttf b/receivers/common/assets/fonts/Outfit/static/Outfit-Medium.ttf new file mode 100644 index 0000000..7ae796b Binary files /dev/null and b/receivers/common/assets/fonts/Outfit/static/Outfit-Medium.ttf differ diff --git a/receivers/common/assets/fonts/Outfit/static/Outfit-Regular.ttf b/receivers/common/assets/fonts/Outfit/static/Outfit-Regular.ttf new file mode 100644 index 0000000..826899c Binary files /dev/null and b/receivers/common/assets/fonts/Outfit/static/Outfit-Regular.ttf differ diff --git a/receivers/common/assets/fonts/Outfit/static/Outfit-SemiBold.ttf b/receivers/common/assets/fonts/Outfit/static/Outfit-SemiBold.ttf new file mode 100644 index 0000000..6b37eeb Binary files /dev/null and b/receivers/common/assets/fonts/Outfit/static/Outfit-SemiBold.ttf differ diff --git a/receivers/common/assets/fonts/Outfit/static/Outfit-Thin.ttf b/receivers/common/assets/fonts/Outfit/static/Outfit-Thin.ttf new file mode 100644 index 0000000..7d84201 Binary files /dev/null and b/receivers/common/assets/fonts/Outfit/static/Outfit-Thin.ttf differ diff --git a/receivers/common/assets/fonts/inter.css b/receivers/common/assets/fonts/inter.css new file mode 100644 index 0000000..505262d --- /dev/null +++ b/receivers/common/assets/fonts/inter.css @@ -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"); } diff --git a/receivers/common/assets/fonts/outfit.css b/receivers/common/assets/fonts/outfit.css new file mode 100644 index 0000000..c320ab1 --- /dev/null +++ b/receivers/common/assets/fonts/outfit.css @@ -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"); } diff --git a/receivers/common/assets/icons/app/checked.svg b/receivers/common/assets/icons/app/checked.svg new file mode 100644 index 0000000..3c90550 --- /dev/null +++ b/receivers/common/assets/icons/app/checked.svg @@ -0,0 +1,4 @@ + + + + diff --git a/receivers/common/assets/icons/app/error.svg b/receivers/common/assets/icons/app/error.svg new file mode 100644 index 0000000..0b8368e --- /dev/null +++ b/receivers/common/assets/icons/app/error.svg @@ -0,0 +1,4 @@ + + + + diff --git a/receivers/common/assets/icons/app/icon.png b/receivers/common/assets/icons/app/icon.png new file mode 100644 index 0000000..5470757 Binary files /dev/null and b/receivers/common/assets/icons/app/icon.png differ diff --git a/receivers/electron/assets/icons/app/icon.svg b/receivers/common/assets/icons/app/icon.svg similarity index 100% rename from receivers/electron/assets/icons/app/icon.svg rename to receivers/common/assets/icons/app/icon.svg diff --git a/receivers/common/assets/icons/app/info.svg b/receivers/common/assets/icons/app/info.svg new file mode 100644 index 0000000..98ab77b --- /dev/null +++ b/receivers/common/assets/icons/app/info.svg @@ -0,0 +1,3 @@ + + + diff --git a/receivers/electron/assets/icons/player/icon24_cc_off.svg b/receivers/common/assets/icons/player/icon24_cc_off.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_cc_off.svg rename to receivers/common/assets/icons/player/icon24_cc_off.svg diff --git a/receivers/electron/assets/icons/player/icon24_cc_off_active.svg b/receivers/common/assets/icons/player/icon24_cc_off_active.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_cc_off_active.svg rename to receivers/common/assets/icons/player/icon24_cc_off_active.svg diff --git a/receivers/electron/assets/icons/player/icon24_cc_on.svg b/receivers/common/assets/icons/player/icon24_cc_on.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_cc_on.svg rename to receivers/common/assets/icons/player/icon24_cc_on.svg diff --git a/receivers/electron/assets/icons/player/icon24_cc_on_active.svg b/receivers/common/assets/icons/player/icon24_cc_on_active.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_cc_on_active.svg rename to receivers/common/assets/icons/player/icon24_cc_on_active.svg diff --git a/receivers/electron/assets/icons/player/icon24_check_thin.svg b/receivers/common/assets/icons/player/icon24_check_thin.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_check_thin.svg rename to receivers/common/assets/icons/player/icon24_check_thin.svg diff --git a/receivers/electron/assets/icons/player/icon24_fullscreen_off.svg b/receivers/common/assets/icons/player/icon24_fullscreen_off.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_fullscreen_off.svg rename to receivers/common/assets/icons/player/icon24_fullscreen_off.svg diff --git a/receivers/electron/assets/icons/player/icon24_fullscreen_off_active.svg b/receivers/common/assets/icons/player/icon24_fullscreen_off_active.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_fullscreen_off_active.svg rename to receivers/common/assets/icons/player/icon24_fullscreen_off_active.svg diff --git a/receivers/electron/assets/icons/player/icon24_fullscreen_on.svg b/receivers/common/assets/icons/player/icon24_fullscreen_on.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_fullscreen_on.svg rename to receivers/common/assets/icons/player/icon24_fullscreen_on.svg diff --git a/receivers/electron/assets/icons/player/icon24_fullscreen_on_active.svg b/receivers/common/assets/icons/player/icon24_fullscreen_on_active.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_fullscreen_on_active.svg rename to receivers/common/assets/icons/player/icon24_fullscreen_on_active.svg diff --git a/receivers/electron/assets/icons/player/icon24_mute.svg b/receivers/common/assets/icons/player/icon24_mute.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_mute.svg rename to receivers/common/assets/icons/player/icon24_mute.svg diff --git a/receivers/electron/assets/icons/player/icon24_mute_active.svg b/receivers/common/assets/icons/player/icon24_mute_active.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_mute_active.svg rename to receivers/common/assets/icons/player/icon24_mute_active.svg diff --git a/receivers/electron/assets/icons/player/icon24_pause.svg b/receivers/common/assets/icons/player/icon24_pause.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_pause.svg rename to receivers/common/assets/icons/player/icon24_pause.svg diff --git a/receivers/electron/assets/icons/player/icon24_pause_active.svg b/receivers/common/assets/icons/player/icon24_pause_active.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_pause_active.svg rename to receivers/common/assets/icons/player/icon24_pause_active.svg diff --git a/receivers/electron/assets/icons/player/icon24_play.svg b/receivers/common/assets/icons/player/icon24_play.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_play.svg rename to receivers/common/assets/icons/player/icon24_play.svg diff --git a/receivers/electron/assets/icons/player/icon24_play_active.svg b/receivers/common/assets/icons/player/icon24_play_active.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_play_active.svg rename to receivers/common/assets/icons/player/icon24_play_active.svg diff --git a/receivers/electron/assets/icons/player/icon24_speed.svg b/receivers/common/assets/icons/player/icon24_speed.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_speed.svg rename to receivers/common/assets/icons/player/icon24_speed.svg diff --git a/receivers/electron/assets/icons/player/icon24_speed_active.svg b/receivers/common/assets/icons/player/icon24_speed_active.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_speed_active.svg rename to receivers/common/assets/icons/player/icon24_speed_active.svg diff --git a/receivers/electron/assets/icons/player/icon24_volume_less_50pct.svg b/receivers/common/assets/icons/player/icon24_volume_less_50pct.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_volume_less_50pct.svg rename to receivers/common/assets/icons/player/icon24_volume_less_50pct.svg diff --git a/receivers/electron/assets/icons/player/icon24_volume_less_50pct_active.svg b/receivers/common/assets/icons/player/icon24_volume_less_50pct_active.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_volume_less_50pct_active.svg rename to receivers/common/assets/icons/player/icon24_volume_less_50pct_active.svg diff --git a/receivers/electron/assets/icons/player/icon24_volume_more_50pct.svg b/receivers/common/assets/icons/player/icon24_volume_more_50pct.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_volume_more_50pct.svg rename to receivers/common/assets/icons/player/icon24_volume_more_50pct.svg diff --git a/receivers/electron/assets/icons/player/icon24_volume_more_50pct_active.svg b/receivers/common/assets/icons/player/icon24_volume_more_50pct_active.svg similarity index 100% rename from receivers/electron/assets/icons/player/icon24_volume_more_50pct_active.svg rename to receivers/common/assets/icons/player/icon24_volume_more_50pct_active.svg diff --git a/receivers/electron/assets/video/background.mp4 b/receivers/common/assets/video/background.mp4 similarity index 100% rename from receivers/electron/assets/video/background.mp4 rename to receivers/common/assets/video/background.mp4 diff --git a/receivers/common/web/DiscoveryService.ts b/receivers/common/web/DiscoveryService.ts new file mode 100644 index 0000000..b78d3ad --- /dev/null +++ b/receivers/common/web/DiscoveryService.ts @@ -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; + } + } +} diff --git a/receivers/electron/src/FCastSession.ts b/receivers/common/web/FCastSession.ts similarity index 81% rename from receivers/electron/src/FCastSession.ts rename to receivers/common/web/FCastSession.ts index 755f74c..011d749 100644 --- a/receivers/electron/src/FCastSession.ts +++ b/receivers/common/web/FCastSession.ts @@ -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") }); } -} \ No newline at end of file +} diff --git a/receivers/common/web/NetworkService.ts b/receivers/common/web/NetworkService.ts new file mode 100644 index 0000000..bfcd8da --- /dev/null +++ b/receivers/common/web/NetworkService.ts @@ -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 = new Map(); + + private static setupProxyServer(): Promise { + return new Promise((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 { + 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 { + 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; + } +} diff --git a/receivers/electron/src/Packets.ts b/receivers/common/web/Packets.ts similarity index 99% rename from receivers/electron/src/Packets.ts rename to receivers/common/web/Packets.ts index 267b6d3..d90edd6 100644 --- a/receivers/electron/src/Packets.ts +++ b/receivers/common/web/Packets.ts @@ -54,4 +54,4 @@ export class VersionMessage { constructor( public version: number, ) {} -} \ No newline at end of file +} diff --git a/receivers/electron/src/TcpListenerService.ts b/receivers/common/web/TcpListenerService.ts similarity index 58% rename from receivers/electron/src/TcpListenerService.ts rename to receivers/common/web/TcpListenerService.ts index 59fc97c..90cb655 100644 --- a/receivers/electron/src/TcpListenerService.ts +++ b/receivers/common/web/TcpListenerService.ts @@ -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); } } -} \ No newline at end of file +} diff --git a/receivers/electron/src/WebSocketListenerService.ts b/receivers/common/web/WebSocketListenerService.ts similarity index 74% rename from receivers/electron/src/WebSocketListenerService.ts rename to receivers/common/web/WebSocketListenerService.ts index 5cfa31a..e2b2b70 100644 --- a/receivers/electron/src/WebSocketListenerService.ts +++ b/receivers/common/web/WebSocketListenerService.ts @@ -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'); } } -} \ No newline at end of file +} diff --git a/receivers/common/web/components/Toast.ts b/receivers/common/web/components/Toast.ts new file mode 100644 index 0000000..32c44e9 --- /dev/null +++ b/receivers/common/web/components/Toast.ts @@ -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'; +} diff --git a/receivers/common/web/main/Preload.ts b/receivers/common/web/main/Preload.ts new file mode 100644 index 0000000..7e8c06c --- /dev/null +++ b/receivers/common/web/main/Preload.ts @@ -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 = {}; + +// @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 +}; diff --git a/receivers/common/web/main/Renderer.ts b/receivers/common/web/main/Renderer.ts new file mode 100644 index 0000000..46f2088 --- /dev/null +++ b/receivers/common/web/main/Renderer.ts @@ -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
${value.addresses.join('
')}`; + } + + 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(); +} diff --git a/receivers/common/web/main/common.css b/receivers/common/web/main/common.css new file mode 100644 index 0000000..792a952 --- /dev/null +++ b/receivers/common/web/main/common.css @@ -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; + } +} diff --git a/receivers/electron/src/player/Player.ts b/receivers/common/web/player/Player.ts similarity index 99% rename from receivers/electron/src/player/Player.ts rename to receivers/common/web/player/Player.ts index 550f3b9..d44e9d4 100644 --- a/receivers/electron/src/player/Player.ts +++ b/receivers/common/web/player/Player.ts @@ -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, diff --git a/receivers/common/web/player/Preload.ts b/receivers/common/web/player/Preload.ts new file mode 100644 index 0000000..e8788dc --- /dev/null +++ b/receivers/common/web/player/Preload.ts @@ -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 = {}; + +// @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 +}; diff --git a/receivers/common/web/player/Renderer.ts b/receivers/common/web/player/Renderer.ts new file mode 100644 index 0000000..2960a77 --- /dev/null +++ b/receivers/common/web/player/Renderer.ts @@ -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, +}; diff --git a/receivers/common/web/player/common.css b/receivers/common/web/player/common.css new file mode 100644 index 0000000..087b8f5 --- /dev/null +++ b/receivers/common/web/player/common.css @@ -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; +} diff --git a/receivers/electron/Dockerfile b/receivers/electron/Dockerfile index 8036444..272ab85 100644 --- a/receivers/electron/Dockerfile +++ b/receivers/electron/Dockerfile @@ -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 diff --git a/receivers/electron/assets/fonts/inter.css b/receivers/electron/assets/fonts/inter.css deleted file mode 100644 index 1ea5ba4..0000000 --- a/receivers/electron/assets/fonts/inter.css +++ /dev/null @@ -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"); -} diff --git a/receivers/electron/assets/fonts/outfit.css b/receivers/electron/assets/fonts/outfit.css deleted file mode 100644 index 2378960..0000000 --- a/receivers/electron/assets/fonts/outfit.css +++ /dev/null @@ -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"); -} diff --git a/receivers/electron/package-lock.json b/receivers/electron/package-lock.json index 309e58f..66c6fe9 100644 --- a/receivers/electron/package-lock.json +++ b/receivers/electron/package-lock.json @@ -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": { diff --git a/receivers/electron/package.json b/receivers/electron/package.json index 5fc6529..2d79424 100644 --- a/receivers/electron/package.json +++ b/receivers/electron/package.json @@ -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" } } diff --git a/receivers/electron/src/App.ts b/receivers/electron/src/App.ts index 779094c..5831c28 100644 --- a/receivers/electron/src/App.ts +++ b/receivers/electron/src/App.ts @@ -1,4 +1,4 @@ import { app } from 'electron'; -import Main from './Main'; +import { Main } from './Main'; await Main.main(app); \ No newline at end of file diff --git a/receivers/electron/src/DiscoveryService.ts b/receivers/electron/src/DiscoveryService.ts deleted file mode 100644 index 20925d4..0000000 --- a/receivers/electron/src/DiscoveryService.ts +++ /dev/null @@ -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; - } - } -} \ No newline at end of file diff --git a/receivers/electron/src/Main.ts b/receivers/electron/src/Main.ts index c762256..3548d1e 100644 --- a/receivers/electron/src/Main.ts +++ b/receivers/electron/src/Main.ts @@ -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 = 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 { - return new Promise((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 { - 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 { - 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); + } +} diff --git a/receivers/electron/src/main/Preload.ts b/receivers/electron/src/main/Preload.ts index cd8ea7b..509b0b1 100644 --- a/receivers/electron/src/main/Preload.ts +++ b/receivers/electron/src/main/Preload.ts @@ -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, }); diff --git a/receivers/electron/src/main/Renderer.ts b/receivers/electron/src/main/Renderer.ts index 11d3865..b5c83c2 100644 --- a/receivers/electron/src/main/Renderer.ts +++ b/receivers/electron/src/main/Renderer.ts @@ -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
${value.addresses.join('
')}`; - } - - 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`); diff --git a/receivers/electron/src/main/index.html b/receivers/electron/src/main/index.html index 2f51074..1a6e587 100644 --- a/receivers/electron/src/main/index.html +++ b/receivers/electron/src/main/index.html @@ -1,16 +1,17 @@ - - - - FCast Receiver + + + + +
@@ -21,8 +22,9 @@
-
Waiting for a connection
-
+
Waiting for a connection
+
+
@@ -54,6 +56,10 @@
+
+
+
+
App will continue to run as tray app when the window is closed
diff --git a/receivers/electron/src/main/style.css b/receivers/electron/src/main/style.css index aad6665..95dfbb6 100644 --- a/receivers/electron/src/main/style.css +++ b/receivers/electron/src/main/style.css @@ -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); - } -} diff --git a/receivers/electron/src/player/Preload.ts b/receivers/electron/src/player/Preload.ts index e54c642..0f5b700 100644 --- a/receivers/electron/src/player/Preload.ts +++ b/receivers/electron/src/player/Preload.ts @@ -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) }); diff --git a/receivers/electron/src/player/Renderer.ts b/receivers/electron/src/player/Renderer.ts index 94d3dd5..761c052 100644 --- a/receivers/electron/src/player/Renderer.ts +++ b/receivers/electron/src/player/Renderer.ts @@ -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, +} diff --git a/receivers/electron/src/player/index.html b/receivers/electron/src/player/index.html index 3ddd6b1..05b60f4 100644 --- a/receivers/electron/src/player/index.html +++ b/receivers/electron/src/player/index.html @@ -1,10 +1,11 @@ - - - FCast Receiver + + + + @@ -22,9 +23,9 @@
-
+
-
+
@@ -35,14 +36,15 @@
00:00
-
/  00:00
+
/  
+
00:00
-
-
-
+
+
+