mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Compare commits
200 Commits
218
...
#1-rm-you.be
| Author | SHA1 | Date | |
|---|---|---|---|
| eb8d9ea9a3 | |||
| 4d93246863 | |||
| 0471886d9f | |||
| 266974b799 | |||
| c3663c67d7 | |||
| 07bb23d10b | |||
| 749fc22c6b | |||
| 9f9a4e8298 | |||
| 39e7d64d3f | |||
| 35d8610c00 | |||
| bc550ae8f5 | |||
| c76ef7f19b | |||
| b7781264d3 | |||
| 696e03941a | |||
| 4609a351dc | |||
| c275415a49 | |||
| 486ebd6bc8 | |||
| 74b9926647 | |||
| 2a6ba6d541 | |||
| 931216ab7d | |||
| 916936e179 | |||
| b535353365 | |||
| be2ae096ee | |||
| 948b85ddcb | |||
| ae904b4cd8 | |||
| aad50e7b50 | |||
| ff28a07871 | |||
| 414b6e24d2 | |||
| 9499afd815 | |||
| e7aca5cd25 | |||
| 80a6a8ac9f | |||
| c3428a695f | |||
| 1a9665b5c6 | |||
| ebb4693425 | |||
| 4f09f48ace | |||
| a0d6ff912b | |||
| a345da0feb | |||
| fc5a8d9531 | |||
| 7353edb058 | |||
| 2a7c0a5c79 | |||
| 4cf3aabe89 | |||
| ef284ba51d | |||
| 5edd389e84 | |||
| 309332ac9c | |||
| 035d19f581 | |||
| 72bb43f934 | |||
| 447ed6bf21 | |||
| db1bcfcc6b | |||
| 1ccae84933 | |||
| 152b9b23cd | |||
| a3070d8d08 | |||
| aceab7b476 | |||
| 5f1c0209a8 | |||
| 819e81b7a6 | |||
| 8193234c2f | |||
| 6263a31f41 | |||
| 481a0cda99 | |||
| b39b89e908 | |||
| ce0f98055f | |||
| 3dddf68766 | |||
| 88d687f26e | |||
| d44df42727 | |||
| 88c8dbcb7c | |||
| b4fddbe26a | |||
| ab6d7669d7 | |||
| 3f22c7f717 | |||
| f36e9588cb | |||
| 8f99f399ee | |||
| 56166a7948 | |||
| 4edd8ee1ea | |||
| a830c918ab | |||
| 53f74c4b6e | |||
| 959c192762 | |||
| 8be7b1272b | |||
| 6b57878275 | |||
| 66c7741c38 | |||
| b370af9d91 | |||
| 40b86cb5de | |||
| 84622e22aa | |||
| 092b20041e | |||
| f6cc00f471 | |||
| be2067067b | |||
| 67a7dd9698 | |||
| 6ffc067b24 | |||
| 56e6314c11 | |||
| e590bb4a19 | |||
| 35fe7f0e7a | |||
| 45d818ac81 | |||
| 7729681829 | |||
| b12d04b27d | |||
| e6608b9a5c | |||
| 2d503dfaf6 | |||
| 08934ef8de | |||
| 62d927739a | |||
| c8db8f58e8 | |||
| 0fc966a77d | |||
| 9f6c6c8cf3 | |||
| 43a6ff138c | |||
| 269a3460e7 | |||
| 18150e9e15 | |||
| 362c7f5b2c | |||
| 2adb8ad7f9 | |||
| 6b5d4e7507 | |||
| 49c82726f0 | |||
| c8ddcda384 | |||
| b75217f789 | |||
| 8ba8e535bd | |||
| e4c574db6b | |||
| fae73293d7 | |||
| 3bd0aac4f8 | |||
| 26b822e04b | |||
| 96b9b8843c | |||
| 6d9c1e17b5 | |||
| 507ad105c0 | |||
| 40a283017e | |||
| be14597670 | |||
| 837609abb9 | |||
| d64cd98b43 | |||
| 0081ff1483 | |||
| f78ca6c7ed | |||
| cfc7cbcaa4 | |||
| e533eb7778 | |||
| 7c1d0a7f88 | |||
| 01ef471708 | |||
| 2fd0a9a41d | |||
| 635749dfe4 | |||
| c4bd5626f3 | |||
| 568a0f6329 | |||
| 7ee67b5cd0 | |||
| fc94c6903c | |||
| a0af8805e7 | |||
| 9b64cde17d | |||
| f6931bcf8c | |||
| a4ff47d863 | |||
| 982d251126 | |||
| 8820a0ecc0 | |||
| b99a713ffc | |||
| dfc8c4b740 | |||
| c3df9e5259 | |||
| b9c7e0a8ca | |||
| 2c7f02a24d | |||
| 5cc8488d94 | |||
| 6f7304f59c | |||
| ea4fea4401 | |||
| 9b48664de4 | |||
| 8964dc68f0 | |||
| 4711b8055b | |||
| 84e3373fa7 | |||
| fdd7e32dd8 | |||
| e57119ebbd | |||
| ed29dd8365 | |||
| 196cacb452 | |||
| c025913fc8 | |||
| 48b2c68e72 | |||
| 689766a6ac | |||
| 9306024d17 | |||
| 195163840b | |||
| 788c54bf8f | |||
| 031aabd523 | |||
| 85db4cc4e6 | |||
| 745aad385b | |||
| ba87261f9f | |||
| 7d091382c0 | |||
| 781d0797e7 | |||
| ec12a06b88 | |||
| bf3e8867c3 | |||
| 176814a715 | |||
| 898637a616 | |||
| f1860126a7 | |||
| f8402676d7 | |||
| cf86ce1ab3 | |||
| f4cb1719e0 | |||
| 4898cb53ae | |||
| 0f60d4737e | |||
| 0dc33e1f2b | |||
| d86a997a88 | |||
| 34d4d92289 | |||
| 4cb1bf268f | |||
| 8488706ff9 | |||
| a348bb2662 | |||
| 60a17b3c67 | |||
| 386c58d4ad | |||
| 356ba01dc1 | |||
| ed2aa848da | |||
| c5dd90048f | |||
| ab04f334dc | |||
| 0d44f8a416 | |||
| d01a1545e2 | |||
| e599729ba1 | |||
| 3ac043740e | |||
| 89603d0ff3 | |||
| 05b6cd7c97 | |||
| ea5aad0631 | |||
| 96e034b9bf | |||
| 6141c36855 | |||
| 4084ab3ed0 | |||
| 34e733823a | |||
| f1d01642cd | |||
| d5551d7118 | |||
| d079a1e8e4 |
@@ -0,0 +1,78 @@
|
||||
name: Bug Report
|
||||
description: Let us know about an unexpected error, a crash, or an incorrect behavior.
|
||||
labels: ["bug", "new"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Thank you for taking the time to fill out this bug report.
|
||||
|
||||
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application
|
||||
|
||||
For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
|
||||
|
||||
## Filing a bug report
|
||||
|
||||
To fix your issues faster, we need clear reproduction cases - ideally allowing us to make it happen locally.
|
||||
* Please include all needed context. For example, Device, OS, Application, your Grayjay Configurations and Plugin versioning info.
|
||||
* if you've found out a particular series of UI interactions can introduce buggy behavior, please label those steps 1-n with markdown
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: What did you expect to happen?
|
||||
placeholder: Tell us what you see!
|
||||
value: "A bug happened!"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: grayjay-version
|
||||
attributes:
|
||||
label: Grayjay Version
|
||||
description: In the application, select More > Settings, scroll to the bottom and locate the value next to "Version Name".
|
||||
render: shell
|
||||
placeholder: "242"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: plugin
|
||||
attributes:
|
||||
label: What plugins are you seeing the problem on?
|
||||
multiple: true
|
||||
options:
|
||||
- All
|
||||
- Youtube
|
||||
- BiliBili (CN)
|
||||
- Twitch (Beta)
|
||||
- Odysee
|
||||
- Rumble
|
||||
- Kick (Beta)
|
||||
- PeerTube
|
||||
- Patreon
|
||||
- Nebula (Beta)
|
||||
- SoundCloud
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: login
|
||||
attributes:
|
||||
label: Are you experiencing the issue when logged in?
|
||||
multiple: false
|
||||
options:
|
||||
- "Yes"
|
||||
- "No"
|
||||
- N/A
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Need a Grayjay License?
|
||||
url: https://pay.futo.org/api/PaymentPortal
|
||||
about: Purchase a Grayjay license with FutoPay
|
||||
- name: Plugin Building, Usage, or other Questions
|
||||
url: https://chat.futo.org/#narrow/stream/46-Grayjay
|
||||
about: Grayjays Community Chat
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Documentation Issue
|
||||
description: Report an issue or suggest a change in the documentation.
|
||||
labels: ["documentation", "new"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Thank you for opening a documentation change request.
|
||||
|
||||
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application. `Documentation` issue type to report problems with the documentation in our code repositories, inside the Application, or on [https://grayjay.app/](https://grayjay.app)
|
||||
Technical writers monitor this issue type. Report Grayjay bugs or feature requests with the `Bug report` or `Feature Request` issue types instead to get engineering attention.
|
||||
|
||||
For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
|
||||
|
||||
- type: textarea
|
||||
id: grayjay-affected-pages
|
||||
attributes:
|
||||
label: Affected Pages
|
||||
description: |
|
||||
Link to or describe the pages relevant to your documentation change request.
|
||||
placeholder:
|
||||
value:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: grayjay-problem
|
||||
attributes:
|
||||
label: What is the docs issue?
|
||||
description: What problems or suggestions do you have about the documentation?
|
||||
placeholder:
|
||||
value:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: grayjay-proposal
|
||||
attributes:
|
||||
label: Proposal
|
||||
description: What documentation changes would fix this issue and where would you expect to find them? Are one or more page headings unclear? Do one or more pages need additional context, examples, or warnings? Do we need a new page or section dedicated to a specific topic? Your ideas help us understand what you and other users need from our documentation and how we can improve the content.
|
||||
placeholder:
|
||||
value:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: grayjay-references
|
||||
attributes:
|
||||
label: References
|
||||
description: |
|
||||
Are there any other open or closed GitLab/GitHub issues related to the problem or solution you described? If so, list them below. For example:
|
||||
```
|
||||
- #6017
|
||||
```
|
||||
placeholder:
|
||||
value:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Note:** If the submit button is disabled and you have filled out all required fields, please check that you did not forget a **Title** for the issue.
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or other enhancement.
|
||||
labels: ["enhancement", "new"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Thank you for opening a feature request.
|
||||
|
||||
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application
|
||||
|
||||
[External Contributions are close at this time](https://github.com/tom-futo/grayjay-android/blob/master/CONTRIBUTION.md#contributing-to-core)
|
||||
|
||||
For discussion related to enhancements, please see: [The FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
|
||||
|
||||
- type: textarea
|
||||
id: grayjay-use-case
|
||||
attributes:
|
||||
label: Use Cases
|
||||
description: |
|
||||
In order to properly evaluate a feature request, it is necessary to understand the use cases for it.
|
||||
Please describe below the _end goal_ you are trying to achieve that has led you to request this feature.
|
||||
Please keep this section focused on the problem and not on the suggested solution.
|
||||
placeholder:
|
||||
value:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: grayjay-proposal
|
||||
attributes:
|
||||
label: Proposal
|
||||
description: |
|
||||
If you have an idea for a way to address the problem via a change to Grayjay features, please describe it below.
|
||||
In this section, it's helpful to include specific examples of how what you are suggesting might look in the application, this allows us to understand the full picture of what you are proposing.
|
||||
If you're not sure of some details, don't worry! When we evaluate the feature request we may suggest modifications as necessary to work within the design constraints of the Grayjay Core Application.
|
||||
placeholder:
|
||||
value:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: grayjay-references
|
||||
attributes:
|
||||
label: References
|
||||
description: |
|
||||
Are there any other GitHub issues, whether open or closed, that are related to the problem you've described above or to the suggested solution? If so, please create a list below that mentions each of them. For example:
|
||||
```
|
||||
- #10
|
||||
```
|
||||
placeholder:
|
||||
value:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Note:** If the submit button is disabled and you have filled out all required fields, please check that you did not forget a **Title** for the issue.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Issue labeler
|
||||
on:
|
||||
issues:
|
||||
types: [ opened ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
label-component:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
# required for all workflows
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Parse issue form
|
||||
uses: stefanbuck/github-issue-parser@v3
|
||||
id: issue-parser
|
||||
with:
|
||||
template-path: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||
|
||||
- name: Set labels based on plugin field
|
||||
uses: redhat-plumbers-in-action/advanced-issue-labeler@v2
|
||||
with:
|
||||
issue-form: ${{ steps.issue-parser.outputs.jsonString }}
|
||||
section: plugin
|
||||
block-list: |
|
||||
None
|
||||
Other
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
+12
-3
@@ -1,9 +1,6 @@
|
||||
[submodule "dep/polycentricandroid"]
|
||||
path = dep/polycentricandroid
|
||||
url = ../polycentricandroid.git
|
||||
[submodule "app/src/playstore/assets/sources/peertube"]
|
||||
path = app/src/playstore/assets/sources/peertube
|
||||
url = ../plugins/peertube.git
|
||||
[submodule "app/src/stable/assets/sources/kick"]
|
||||
path = app/src/stable/assets/sources/kick
|
||||
url = ../plugins/kick.git
|
||||
@@ -61,3 +58,15 @@
|
||||
[submodule "dep/futopay"]
|
||||
path = dep/futopay
|
||||
url = ../futopayclientlibraries.git
|
||||
[submodule "app/src/unstable/assets/sources/bilibili"]
|
||||
path = app/src/unstable/assets/sources/bilibili
|
||||
url = ../plugins/bilibili.git
|
||||
[submodule "app/src/stable/assets/sources/bilibili"]
|
||||
path = app/src/stable/assets/sources/bilibili
|
||||
url = ../plugins/bilibili.git
|
||||
[submodule "app/src/stable/assets/sources/spotify"]
|
||||
path = app/src/stable/assets/sources/spotify
|
||||
url = ../plugins/spotify.git
|
||||
[submodule "app/src/unstable/assets/sources/spotify"]
|
||||
path = app/src/unstable/assets/sources/spotify
|
||||
url = ../plugins/spotify.git
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# FUTO TEMPORARY LICENSE
|
||||
This license grants you the rights, and only the rights, set out below in respect of the source code provided. If you take advantage of these rights, you accept this license. If you do not accept the license, do not access the code.
|
||||
|
||||
Words used in the Terms of Service have the same meaning in this license. Where there is any inconsistency between this license and those Terms of Service, these terms prevail.
|
||||
|
||||
## Section 1: Definitions
|
||||
- "code" means the source code made available from time, in our sole discretion, for access under this license. Reference to code in this license means the code and any part of it and any derivative of it.
|
||||
- “compilation” means to compile the code from ‘source code’ to ‘machine code’.
|
||||
- "defect" means a defect, bug, backdoor, security issue or other deficiency in the code.
|
||||
- “non-commercial distribution” means distribution of the code or any compilation of the code, or of any other application or program containing the code or any compilation of the code, where such distribution is not intended for or directed towards commercial advantage or monetary compensation.
|
||||
- "review" means to access, analyse, test and otherwise review the code as a reference, for the sole purpose of analysing it for defects.
|
||||
- "you" means the licensee of rights set out in this license.
|
||||
|
||||
## Section 2: Grant of Rights
|
||||
1. Subject to the terms of this license, we grant you a non-transferable, non-exclusive, worldwide, royalty-free license to access and use the code solely for the purposes of review, compilation and non-commercial distribution.
|
||||
2. You may provide the code to anyone else and publish excerpts of it for the purposes of review, compilation and non-commercial distribution, provided that when you do so you make any recipient of the code aware of the terms of this license, they must agree to be bound by the terms of this license and you must attribute the code to the provider.
|
||||
3. Other than in respect of those parts of the code that were developed by other parties and as specified strictly in accordance with the open source and other licenses under which those parts of the code have been made available, as set out on our website or in those items of code, you are not entitled to use or do anything with the code for any commercial or other purpose, other than review, compilation and non-commercial distribution in accordance with the terms of this license.
|
||||
4. Subject to the terms of this license, you must at all times comply with and shall be bound by our Terms of Use, Privacy and Data Policy.
|
||||
|
||||
## Section 3: Limitations
|
||||
1. This license does not grant you any rights to use the provider's name, logo, or trademarks and you must not in any way indicate you are authorised to speak on behalf of the provider.
|
||||
2. If you issue proceedings in any jurisdiction against the provider because you consider the provider has infringed copyright or any patent right in respect of the code (including any joinder or counterclaim), your license to the code is automatically terminated.
|
||||
3. THE CODE IS MADE AVAILABLE "AS-IS" AND WITHOUT ANY EXPRESS OR IMPLIED GUARANTEES AS TO FITNESS, MERCHANTABILITY, NON-INFRINGEMENT OR OTHERWISE. IT IS NOT BEING PROVIDED IN TRADE BUT ON A VOLUNTARY BASIS ON OUR PART AND IS NOT MADE AVAILABLE FOR ANY USE OUTSIDE THE TERMS OF THIS LICENSE. ANYONE ACCESSING THE CODE MUST ENSURE THEY HAVE THE REQUISITE EXPERTISE TO SECURE THEIR OWN SYSTEM AND DEVICES AND TO ACCESS AND USE THE CODE IN ACCORDANCE WITH THE TERMS OF THIS LICENSE. YOU BEAR THE RISK OF ACCESSING AND USING THE CODE. IN PARTICULAR, THE PROVIDER BEARS NO LIABILITY FOR ANY INTERFERENCE WITH OR ADVERSE EFFECT ON YOUR SYSTEM OR DEVICES AS A RESULT OF YOUR ACCESSING AND USING THE CODE IN ACCORDANCE WITH THE TERMS OF THIS LICENSE OR OTHERWISE.
|
||||
|
||||
## Section 4: Termination, suspension and variation
|
||||
1. We may suspend, terminate or vary the terms of this license and any access to the code at any time, without notice, for any reason or no reason, in respect of any licensee, group of licensees or all licensees including as may be applicable any sub-licensees.
|
||||
|
||||
## Section 5: General
|
||||
1. This license and its interpretation and operation are governed solely by the local law. You agree to submit to the exclusive jurisdiction of the local arbitral tribunals as further described in our Terms of Service and you agree not to raise any jurisdictional issue if we need to enforce an arbitral award or judgment in our jurisdiction or another country.
|
||||
2. Questions and comments regarding this license are welcomed and should be addressed at https://chat.futo.org/login/.
|
||||
|
||||
Last updated 7 June 2023.
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Grayjay Core License 1.0
|
||||
|
||||
## Acceptance
|
||||
By using the software, you agree to all of the terms and conditions below.
|
||||
|
||||
## Copyright License
|
||||
FUTO Holdings, Inc. (the “Licensor”) grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject to the limitations below.
|
||||
|
||||
## Limitations
|
||||
You may use or modify the software only for non-commercial purposes such as personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, all without any anticipated commercial application.
|
||||
|
||||
You may distribute the software or provide it to others only if you do so free of charge for non-commercial purposes.
|
||||
|
||||
Notwithstanding the above, you may not remove or obscure any functionality in the software related to payment to the Licensor in any copy you distribute to others.
|
||||
|
||||
You may not alter, remove, or obscure any licensing, copyright, or other notices of the Licensor in the software. Any use of the Licensor’s trademarks is subject to applicable law.
|
||||
|
||||
## Patents
|
||||
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
||||
|
||||
## Notices
|
||||
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms. If you modify the software, you must include in any modified copies of the software a prominent notice stating that you have modified the software, such as but not limited to, a statement in a readme file or an in-application about section.
|
||||
|
||||
## Fair Use
|
||||
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
||||
|
||||
## No Other Rights
|
||||
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the Licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
||||
|
||||
## Termination
|
||||
If you use the software in violation of these terms, such use is not licensed, and your license will automatically terminate. If the licensor provides you with a notice of your violation, and you cease all violation of this license no later than 30 days after you receive that notice, your license will be reinstated retroactively. However, if you violate these terms after such reinstatement, any additional violation of these terms will cause your license to terminate automatically and permanently.
|
||||
|
||||
## No Liability
|
||||
As far as the law allows, the software comes as is, without any warranty or condition, and the Licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.
|
||||
|
||||
## Definitions
|
||||
- The “Licensor” is the entity offering these terms, FUTO Holdings, Inc.
|
||||
- The “software” is the software the licensor makes available under these terms, including any portion of it.
|
||||
- “You” refers to the individual or entity agreeing to these terms.
|
||||
- “Your company” is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. Control means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
||||
- “Your license” is the license granted to you for the software under these terms.
|
||||
- “Use” means anything you do with the software requiring your license.
|
||||
- “Trademark” means trademarks, service marks, and similar rights.
|
||||
+11
-11
@@ -151,7 +151,7 @@ dependencies {
|
||||
//Core
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.10.0'
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
|
||||
//Images
|
||||
@@ -169,18 +169,18 @@ dependencies {
|
||||
implementation 'com.google.code.gson:gson:2.10.1' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
|
||||
|
||||
//JS
|
||||
implementation("com.caoccao.javet:javet-android:2.2.1")
|
||||
implementation("com.caoccao.javet:javet-android:3.0.2")
|
||||
|
||||
//Exoplayer
|
||||
implementation 'androidx.media3:media3-exoplayer:1.2.0'
|
||||
implementation 'androidx.media3:media3-exoplayer-dash:1.2.0'
|
||||
implementation 'androidx.media3:media3-ui:1.2.0'
|
||||
implementation 'androidx.media3:media3-exoplayer-hls:1.2.0'
|
||||
implementation 'androidx.media3:media3-exoplayer-rtsp:1.2.0'
|
||||
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.2.0'
|
||||
implementation 'androidx.media3:media3-transformer:1.2.0'
|
||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.5'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.7.5'
|
||||
implementation 'androidx.media3:media3-exoplayer:1.2.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-dash:1.2.1'
|
||||
implementation 'androidx.media3:media3-ui:1.2.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-hls:1.2.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-rtsp:1.2.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.2.1'
|
||||
implementation 'androidx.media3:media3-transformer:1.2.1'
|
||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.6'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.7.6'
|
||||
implementation 'androidx.media:media:1.7.0'
|
||||
|
||||
//Other
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -24,7 +25,8 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.FutoVideo"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
tools:targetApi="31"
|
||||
android:largeHeap="true">
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="@string/authority"
|
||||
@@ -39,9 +41,6 @@
|
||||
<service android:name=".services.DownloadService"
|
||||
android:enabled="true"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<service android:name=".services.ExportingService"
|
||||
android:enabled="true"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<receiver android:name=".receivers.MediaControlReceiver" />
|
||||
<receiver android:name=".receivers.AudioNoisyReceiver" />
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_287_2206)">
|
||||
<path d="M22.0557 38.25L43.1117 6H1L22.0557 38.25Z" fill="url(#paint0_linear_287_2206)"/>
|
||||
<path d="M6 28.2444C6.85811 27.3291 8.98625 25.2353 10.6338 24.1827C12.2814 23.13 14.257 20.1209 15.0388 18.7479C17.4224 15.2392 22.7618 7.91286 25.0501 6.67716C25.462 6.35678 26.0608 5.85718 26.3087 5.64745C27.1668 3.7405 30.0844 0.498738 34.8898 2.78706C35.3017 2.64974 36.32 2.61542 36.7777 2.61542C36.4153 2.86334 35.6564 3.58795 35.5191 4.50328C35.153 7.02039 33.7647 8.48874 33.1164 8.90825C32.6587 11.8259 32.0294 14.4002 30.6564 15.3155L31.915 17.5466C33.8029 19.5489 37.7159 23.8737 38.2649 25.1552C36.4344 24.5603 35.2521 23.992 34.8898 23.7822L38.2649 28.416C36.2818 28.2635 31.8235 26.9744 29.8556 23.0385C30.6336 25.1438 31.4001 27.7677 31.6862 28.8165C30.6183 27.9393 28.3224 25.3955 27.6816 22.2376C27.8647 25.304 27.8342 27.4816 27.7961 28.1872C27.2812 27.7105 26.0913 26.2307 25.4505 24.1255V27.6723C24.6821 26.604 23.1363 24.0104 22.9967 22.0533C23.1255 24.2716 23.047 25.3115 22.9906 25.5556L20.0731 22.8097C19.2912 23.2292 17.1898 24.1827 15.0388 24.6403C13.5743 25.876 11.797 28.969 11.0915 30.3611V28.5877L9.14643 30.5327L9.83291 28.4733L8.57433 29.5602C8.28828 29.7318 7.62468 30.0751 7.25857 30.0751C7.39585 29.7547 7.65904 29.4076 7.77345 29.2741L6.11441 29.9034C6.3051 29.3504 6.90388 28.13 7.77345 27.6723C6.58351 28.13 6.09536 28.2444 6 28.2444Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_287_2206" x1="22.0557" y1="38.25" x2="22.0557" y2="-4.75" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#01D6E6"/>
|
||||
<stop offset="1" stop-color="#0182E7"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_287_2206">
|
||||
<rect width="44" height="44" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -233,6 +233,9 @@ function pluginRemoteProp(objID, propName) {
|
||||
function pluginRemoteCall(objID, methodName, args) {
|
||||
return JSON.parse(syncPOST("/plugin/remoteCall?id=" + objID + "&method=" + methodName, {}, JSON.stringify(args)));
|
||||
}
|
||||
function pluginRemoteTest(methodName, args) {
|
||||
return JSON.parse(syncPOST("/plugin/remoteTest?method=" + methodName, {}, JSON.stringify(args)));
|
||||
}
|
||||
|
||||
function pluginIsLoggedIn(cb, err) {
|
||||
fetch("/plugin/isLoggedIn", {
|
||||
@@ -259,6 +262,17 @@ function getDevLogs(lastIndex, cb) {
|
||||
.then(x=>x.json())
|
||||
.then(y=> cb && cb(y));
|
||||
}
|
||||
function getDevHttpExchanges(cb) {
|
||||
fetch("/plugin/getDevHttpExchanges", {
|
||||
timeout: 1000
|
||||
})
|
||||
.then(x=>x.json())
|
||||
.then(y=> cb && cb(y));
|
||||
}
|
||||
function setDevHttpProxy(url, port) {
|
||||
return fetch("/dev/setDevProxy?url=" + encodeURIComponent(url) + "&port=" + port)
|
||||
.then(x=>x.json());
|
||||
}
|
||||
function sendFakeDevLog(devId, msg) {
|
||||
return syncGET("/plugin/fakeDevLog?devId=" + devId + "&msg=" + msg, {});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
<!--<link href="./dependencies/vuetify.min.css" rel="stylesheet">-->
|
||||
<link href="https://cdn.jsdelivr.net/npm/vuetify@2.7.1/dist/vuetify.min.css" rel="stylesheet">
|
||||
|
||||
<title>DevPortal</title>
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.svg">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
|
||||
|
||||
<style>
|
||||
@@ -150,7 +153,7 @@
|
||||
.pastPluginUrl {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 500px;
|
||||
width: 700px;
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
@@ -160,13 +163,122 @@
|
||||
box-shadow: 0px 1px 2px #131313;
|
||||
font-weight: lighter;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.pastPluginUrl .deleteButton {
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
height: 100%;
|
||||
width: 30px;
|
||||
top: 0px;
|
||||
padding-top: 2px;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-weight: 400;
|
||||
transform: scaleX(1.5);
|
||||
}
|
||||
|
||||
[v-cloak] {
|
||||
display: none;
|
||||
}
|
||||
#cloakLoader {
|
||||
display: block;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: black;
|
||||
color: white;
|
||||
padding-top: 50px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
.httpContainer {
|
||||
position: relative;
|
||||
}
|
||||
.httpLine {
|
||||
}
|
||||
.httpLine .request {
|
||||
height: 50px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
.httpLine .request .status {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
width: 40px;
|
||||
top: 10px;
|
||||
padding: 5px;
|
||||
background-color: #333;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
.httpLine .request .status.error {
|
||||
background-color: #880000;
|
||||
}
|
||||
.httpLine .request .status.success {
|
||||
background-color: #008800;
|
||||
}
|
||||
.httpLine .request .status.warn {
|
||||
background-color: #803500;
|
||||
}
|
||||
.httpLine .request .method {
|
||||
position: absolute;
|
||||
left: 55px;
|
||||
top: 10px;
|
||||
padding: 5px;
|
||||
background-color: #333;
|
||||
border-radius: 5px;
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
.httpLine .request .url {
|
||||
position: absolute;
|
||||
left: 110px;
|
||||
top: 10px;
|
||||
padding: 5px;
|
||||
background-color: #333;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.httpLine .response {
|
||||
background-color: #111;
|
||||
margin-left: 55px;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
}
|
||||
.httpLine .response .body{
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
background-color: black;
|
||||
padding: 10px;
|
||||
}
|
||||
.httpLine .response .headers {
|
||||
margin: 10px;
|
||||
}
|
||||
.httpLine .response .headers .key {
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
color: #FFF;
|
||||
}
|
||||
.httpLine .response .headers .value {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
color: #AAA;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<v-app>
|
||||
<v-main>
|
||||
<div v-cloak id="cloakLoader" v-if="!page">
|
||||
<h2>Loading..</h2>
|
||||
First load may take longer
|
||||
</div>
|
||||
<v-main v-cloak>
|
||||
<div id="topMenu">
|
||||
<div style="height: 100%; display: inline-block; padding-left: 10px; padding-right: 20px;">
|
||||
<img src="./dependencies/FutoMainLogo.svg"
|
||||
@@ -250,10 +362,13 @@
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="pastPluginUrls" style="margin-top: 60px;">
|
||||
<div v-if="pastPluginUrls" style="margin-top: 60px; margin-left: 25px;">
|
||||
<h2 style="font-weight: lighter; text-align: center;">Past Plugins</h2>
|
||||
<div class="pastPluginUrl" v-for="pastPluginUrl in pastPluginUrls" @click="this.Plugin.newPluginUrl = pastPluginUrl; loadPlugin(pastPluginUrl)">
|
||||
{{pastPluginUrl}}
|
||||
<div class="deleteButton" @click="(ev)=>{ev.stopPropagation(); deletePastPlugin(pastPluginUrl)}">
|
||||
X
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -385,8 +500,8 @@
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<div style="width: 50%" v-if="Plugin.currentPlugin">
|
||||
<!--Get Home-->
|
||||
<v-card class="requestCard" v-for="req in Testing.requests">
|
||||
<v-text-field v-model="searchTestMethods" label="Search for source methods.." style="margin-left: 35px; margin-right: 35px;"></v-text-field>
|
||||
<v-card class="requestCard" v-for="req in Testing.requests" v-show="req.title.indexOf(searchTestMethods) >= 0">
|
||||
<v-card-text>
|
||||
<div class="title">
|
||||
<span v-if="req.isOptional">(Optional)</span>
|
||||
@@ -402,6 +517,11 @@
|
||||
<div class="code">
|
||||
{{req.code}}
|
||||
</div>
|
||||
<div class="documentation" v-if="req.docUrl" style="position: absolute; right: 15px; top: 15px;">
|
||||
<a :href="req.docUrl" target="_blank">
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<div class="parameter" v-for="parameter in req.parameters">
|
||||
<div class="name">
|
||||
@@ -416,6 +536,9 @@
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn @click="testSourceRemotely(req)">
|
||||
Test Android
|
||||
</v-btn>
|
||||
<v-btn @click="testSource(req)">
|
||||
Test
|
||||
</v-btn>
|
||||
@@ -497,7 +620,62 @@
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn>Clear</v-btn>
|
||||
<v-btn @click="Integration.logs = []">Clear</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
<v-card style="margin: 20px;" v-if="Plugin.currentPlugin && Integration.httpExchanges">
|
||||
<v-card-title>
|
||||
Http Logs
|
||||
</v-card-title>
|
||||
</v-card-header>
|
||||
<v-card-text>
|
||||
<div style="position: absolute; top: 0px; right: 15px;">
|
||||
<v-checkbox v-model="Integration.showHttpRequests" label="Show Http Requests"></v-checkbox>
|
||||
</div>
|
||||
<div class="httpContainer" v-if="Integration.showHttpRequests">
|
||||
<div class="httpLine" v-for="exchange of Integration.httpExchanges">
|
||||
<div class="request" @click="toggleHttpExchange(exchange)">
|
||||
<div :class="[{ success: exchange.response.status < 300, warn: exchange.response.status >= 300 && exchange.response.status < 400, error: exchange.response.status >= 400 }, 'status']">
|
||||
{{exchange.response.status}}
|
||||
</div>
|
||||
<div class="method">
|
||||
{{exchange.request.method}}
|
||||
</div>
|
||||
<div class="url">
|
||||
{{exchange.request.url}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="response" v-if="exchange.response.show">
|
||||
<h2>Request Headers</h2>
|
||||
<div class="headers">
|
||||
<div class="header" v-for="(headerValue, header) in exchange.request.headers">
|
||||
<div class="key">
|
||||
{{header}}
|
||||
</div>
|
||||
<div class="value">
|
||||
{{headerValue}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Response</h2>
|
||||
<div class="headers">
|
||||
<div class="header" v-for="(headerValue, header) in exchange.response.headers">
|
||||
<div class="key">
|
||||
{{header}}
|
||||
</div>
|
||||
<div class="value">
|
||||
{{headerValue}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body">{{exchange.response.body}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn v-if="Integration.showHttpRequests" @click="Integration.httpExchanges = []">Clear</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</div>
|
||||
@@ -535,6 +713,7 @@
|
||||
<!--<script src="./dependencies/vue.js"></script>-->
|
||||
<!--<script src="./dependencies/vuetify.js"></script>-->
|
||||
<script src="./source_docs.js"></script>
|
||||
<script src="./source_doc_urls.js"></script>
|
||||
<script src="./source.js"></script>
|
||||
<script src="./dev_bridge.js"></script>
|
||||
<script>
|
||||
@@ -545,6 +724,7 @@
|
||||
new Vue({
|
||||
el: '#app',
|
||||
data: {
|
||||
searchTestMethods: "",
|
||||
page: "Plugin",
|
||||
pastPluginUrls: [],
|
||||
settings: {},
|
||||
@@ -552,7 +732,9 @@
|
||||
lastLogIndex: -1,
|
||||
lastLogDevID: "",
|
||||
logs: [],
|
||||
lastInjectTime: ""
|
||||
httpExchanges: [],
|
||||
lastInjectTime: "",
|
||||
showHttpRequests: false
|
||||
},
|
||||
Plugin: {
|
||||
loadUsingTag: false,
|
||||
@@ -570,6 +752,9 @@
|
||||
Testing: {
|
||||
requests: sourceDocs.map(x=>{
|
||||
x.parameters.forEach(y=>y.value = null);
|
||||
|
||||
if(sourceDocUrls[x.title])
|
||||
x.docUrl = sourceDocUrls[x.title];
|
||||
return x;
|
||||
}),
|
||||
lastResult: "",
|
||||
@@ -633,6 +818,16 @@
|
||||
});
|
||||
}
|
||||
});
|
||||
if(this.Integration.showHttpRequests) {
|
||||
getDevHttpExchanges((exchanges)=>{
|
||||
Vue.nextTick(()=>{
|
||||
for(i = 0; i < exchanges.length; i++) {
|
||||
exchanges[i].response.show = false;
|
||||
this.Integration.httpExchanges.unshift(exchanges[i]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
catch(ex) {
|
||||
console.error("Failed update", ex);
|
||||
@@ -674,6 +869,12 @@
|
||||
this.reloadPlugin();
|
||||
});
|
||||
},
|
||||
deletePastPlugin(url) {
|
||||
let currentPastPlugins = this.pastPluginUrls;
|
||||
currentPastPlugins = currentPastPlugins.filter(x=>x.toLowerCase() != url.toLowerCase());
|
||||
this.pastPluginUrls = currentPastPlugins;
|
||||
localStorage.setItem("pastPlugins", JSON.stringify(currentPastPlugins));
|
||||
},
|
||||
loginTestPlugin() {
|
||||
pluginLoginTestPlugin();
|
||||
setTimeout(()=>{
|
||||
@@ -860,8 +1061,58 @@
|
||||
"Error: " + ex;
|
||||
}
|
||||
},
|
||||
testSourceRemotely(req) {
|
||||
const name = req.title;
|
||||
const parameterVals = req.parameters.map(x=>{
|
||||
if(x.value && x.value.startsWith && x.value.startsWith("json:"))
|
||||
return JSON.parse(x.value.substring(5));
|
||||
return x.value
|
||||
});
|
||||
|
||||
if(name == "enable") {
|
||||
if(parameterVals.length > 0)
|
||||
parameterVals[0] = this.Plugin.currentPlugin;
|
||||
else
|
||||
parameterVals.push(this.Plugin.currentPlugin);
|
||||
if(parameterVals.length > 1)
|
||||
parameterVals[1] = __DEV_SETTINGS;
|
||||
else
|
||||
parameterVals.push(__DEV_SETTINGS);
|
||||
}
|
||||
|
||||
const func = source[name];
|
||||
if(!func)
|
||||
alert("Test func not found");
|
||||
|
||||
try {
|
||||
const remoteResult = pluginRemoteTest(name, parameterVals);
|
||||
console.log("Result for " + req.title, remoteResult);
|
||||
this.Testing.lastResult = "//Results [" + name + "]\n" +
|
||||
JSON.stringify(remoteResult, null, 3);
|
||||
this.Testing.lastResultError = "";
|
||||
}
|
||||
catch(ex) {
|
||||
if(ex.plugin_type == "CaptchaRequiredException") {
|
||||
let shouldCaptcha = confirm("Do you want to request captcha?");
|
||||
if(shouldCaptcha) {
|
||||
pluginCaptchaTestPlugin(ex.url, ex.body);
|
||||
}
|
||||
}
|
||||
console.error("Failed to run test for " + req.title, ex);
|
||||
this.Testing.lastResult = ""
|
||||
if(ex.message)
|
||||
this.Testing.lastResultError = "//Results [" + name + "]\n\n" +
|
||||
"Error: " + ex.message + "\n\n" + ex.stack;
|
||||
else
|
||||
this.Testing.lastResultError = "//Results [" + name + "]\n\n" +
|
||||
"Error: " + ex;
|
||||
}
|
||||
},
|
||||
showTestResults(results) {
|
||||
|
||||
},
|
||||
toggleHttpExchange(exchange) {
|
||||
exchange.response.show = !exchange.response.show;
|
||||
},
|
||||
copyClipboard(cpy) {
|
||||
if(navigator.clipboard)
|
||||
|
||||
+121
-47
@@ -1,13 +1,37 @@
|
||||
|
||||
declare class ScriptException extends Error {
|
||||
//If only one parameter is provided, acts as msg
|
||||
constructor(type: string, msg: string);
|
||||
}
|
||||
declare class TimeoutException extends ScriptException {
|
||||
|
||||
declare class LoginRequiredException extends ScriptException {
|
||||
constructor(msg: string);
|
||||
}
|
||||
//Alias
|
||||
declare class ScriptLoginRequiredException extends ScriptException {
|
||||
constructor(msg: string);
|
||||
}
|
||||
|
||||
declare class CaptchaRequiredException extends ScriptException {
|
||||
constructor(url: string, body: string);
|
||||
}
|
||||
|
||||
declare class CriticalException extends ScriptException {
|
||||
constructor(msg: string);
|
||||
}
|
||||
|
||||
declare class UnavailableException extends ScriptException {
|
||||
constructor(msg: string);
|
||||
}
|
||||
|
||||
declare class AgeException extends ScriptException {
|
||||
constructor(msg: string);
|
||||
}
|
||||
|
||||
declare class TimeoutException extends ScriptException {
|
||||
constructor(msg: string);
|
||||
}
|
||||
|
||||
declare class ScriptImplementationException extends ScriptException {
|
||||
constructor(msg: string);
|
||||
}
|
||||
@@ -38,16 +62,23 @@ declare class FilterCapability {
|
||||
|
||||
|
||||
declare class PlatformAuthorLink {
|
||||
constructor(id: PlatformID, name: string, url: string, thumbnail: string, subscribers: integer?);
|
||||
constructor(id: PlatformID, name: string, url: string, thumbnail: string, subscribers: integer?, membershipUrl: string?);
|
||||
}
|
||||
|
||||
declare class PlatformAuthorMembershipLink {
|
||||
constructor(id: PlatformID, name: string, url: string, thumbnail: string, subscribers: integer?, membershipUrl: string?);
|
||||
}
|
||||
|
||||
declare interface PlatformContentDef {
|
||||
id: PlatformID,
|
||||
name: string,
|
||||
thumbnails: Thumbnails,
|
||||
author: PlatformAuthorLink,
|
||||
datetime: integer,
|
||||
url: string
|
||||
}
|
||||
declare interface PlatformContent {}
|
||||
|
||||
declare interface PlatformNestedMediaContentDef extends PlatformContentDef {
|
||||
contentUrl: string,
|
||||
contentName: string?,
|
||||
@@ -59,16 +90,26 @@ declare class PlatformNestedMediaContent {
|
||||
constructor(obj: PlatformNestedMediaContentDef);
|
||||
}
|
||||
|
||||
declare interface PlatformLockedContentDef extends PlatformContentDef {
|
||||
contentName: string?,
|
||||
contentThumbnails: Thumbnails?,
|
||||
unlockUrl: string,
|
||||
lockDescription: string?,
|
||||
}
|
||||
declare class PlatformLockedContent {
|
||||
constructor(obj: PlatformLockedContentDef);
|
||||
}
|
||||
|
||||
|
||||
declare interface PlatformVideoDef extends PlatformContentDef {
|
||||
thumbnails: Thumbnails,
|
||||
author: PlatformAuthorLink,
|
||||
|
||||
duration: int,
|
||||
viewCount: long,
|
||||
isLive: boolean
|
||||
isLive: boolean,
|
||||
shareUrl: string?
|
||||
}
|
||||
declare interface PlatformContent {}
|
||||
|
||||
declare class PlatformVideo implements PlatformContent {
|
||||
constructor(obj: PlatformVideoDef);
|
||||
}
|
||||
@@ -77,15 +118,16 @@ declare class PlatformVideo implements PlatformContent {
|
||||
declare interface PlatformVideoDetailsDef extends PlatformVideoDef {
|
||||
description: string,
|
||||
video: VideoSourceDescriptor,
|
||||
live: SubtitleSource[],
|
||||
rating: IRating
|
||||
live: IVideoSource,
|
||||
rating: IRating,
|
||||
subtitles: SubtitleSource[]
|
||||
}
|
||||
declare class PlatformVideoDetails extends PlatformVideo {
|
||||
constructor(obj: PlatformVideoDetailsDef);
|
||||
}
|
||||
|
||||
declare class PlatformPostDef extends PlatformContentDef {
|
||||
thumbnails: string[],
|
||||
declare interface PlatformPostDef extends PlatformContentDef {
|
||||
thumbnails: Thumbnails[],
|
||||
images: string[],
|
||||
description: string
|
||||
}
|
||||
@@ -93,7 +135,7 @@ declare class PlatformPost extends PlatformContent {
|
||||
constructor(obj: PlatformPostDef)
|
||||
}
|
||||
|
||||
declare class PlatformPostDetailsDef extends PlatformPostDef {
|
||||
declare interface PlatformPostDetailsDef extends PlatformPostDef {
|
||||
rating: IRating,
|
||||
textType: int,
|
||||
content: String
|
||||
@@ -110,8 +152,8 @@ declare interface MuxVideoSourceDescriptorDef {
|
||||
isUnMuxed: boolean,
|
||||
videoSources: VideoSource[]
|
||||
}
|
||||
declare class MuxVideoSourceDescriptor implements IVideoSourceDescriptor {
|
||||
constructor(obj: VideoSourceDescriptorDef);
|
||||
declare class VideoSourceDescriptor implements IVideoSourceDescriptor {
|
||||
constructor(videoSourcesOrObj: VideoSource[]);
|
||||
}
|
||||
|
||||
declare interface UnMuxVideoSourceDescriptorDef {
|
||||
@@ -129,7 +171,7 @@ declare interface IVideoSource {
|
||||
declare interface IAudioSource {
|
||||
|
||||
}
|
||||
interface VideoUrlSourceDef implements IVideoSource {
|
||||
declare interface VideoUrlSourceDef implements IVideoSource {
|
||||
width: integer,
|
||||
height: integer,
|
||||
container: string,
|
||||
@@ -139,22 +181,22 @@ interface VideoUrlSourceDef implements IVideoSource {
|
||||
duration: integer,
|
||||
url: string
|
||||
}
|
||||
class VideoUrlSource {
|
||||
declare class VideoUrlSource {
|
||||
constructor(obj: VideoUrlSourceDef);
|
||||
|
||||
getRequestModifier(): RequestModifier?;
|
||||
}
|
||||
interface VideoUrlRangeSourceDef extends VideoUrlSource {
|
||||
declare interface VideoUrlRangeSourceDef extends VideoUrlSource {
|
||||
itagId: integer,
|
||||
initStart: integer,
|
||||
initEnd: integer,
|
||||
indexStart: integer,
|
||||
indexEnd: integer,
|
||||
}
|
||||
class VideoUrlRangeSource extends VideoUrlSource {
|
||||
declare class VideoUrlRangeSource extends VideoUrlSource {
|
||||
constructor(obj: YTVideoSourceDef);
|
||||
}
|
||||
interface AudioUrlSourceDef {
|
||||
declare interface AudioUrlSourceDef {
|
||||
name: string,
|
||||
bitrate: integer,
|
||||
container: string,
|
||||
@@ -163,24 +205,12 @@ interface AudioUrlSourceDef {
|
||||
url: string,
|
||||
language: string
|
||||
}
|
||||
class AudioUrlSource implements IAudioSource {
|
||||
declare class AudioUrlSource implements IAudioSource {
|
||||
constructor(obj: AudioUrlSourceDef);
|
||||
|
||||
getRequestModifier(): RequestModifier?;
|
||||
}
|
||||
interface IRequest {
|
||||
url: string,
|
||||
headers: Map<string, string>
|
||||
}
|
||||
interface IRequestModifierDef {
|
||||
allowByteSkip: boolean
|
||||
}
|
||||
class RequestModifier {
|
||||
constructor(obj: IRequestModifierDef) { }
|
||||
|
||||
modifyRequest(url: string, headers: Map<string, string>): IRequest;
|
||||
}
|
||||
interface AudioUrlRangeSourceDef extends AudioUrlSource {
|
||||
declare interface AudioUrlRangeSourceDef extends AudioUrlSource {
|
||||
itagId: integer,
|
||||
initStart: integer,
|
||||
initEnd: integer,
|
||||
@@ -188,28 +218,44 @@ interface AudioUrlRangeSourceDef extends AudioUrlSource {
|
||||
indexEnd: integer,
|
||||
audioChannels: integer
|
||||
}
|
||||
class AudioUrlRangeSource extends AudioUrlSource {
|
||||
declare class AudioUrlRangeSource extends AudioUrlSource {
|
||||
constructor(obj: AudioUrlRangeSourceDef);
|
||||
}
|
||||
interface HLSSourceDef {
|
||||
declare interface HLSSourceDef {
|
||||
name: string,
|
||||
duration: integer,
|
||||
url: string
|
||||
url: string,
|
||||
priority: boolean?,
|
||||
language: string?
|
||||
}
|
||||
class HLSSource implements IVideoSource {
|
||||
declare class HLSSource implements IVideoSource {
|
||||
constructor(obj: HLSSourceDef);
|
||||
}
|
||||
interface DashSourceDef {
|
||||
declare interface DashSourceDef {
|
||||
name: string,
|
||||
duration: integer,
|
||||
url: string
|
||||
url: string,
|
||||
language: string?
|
||||
}
|
||||
class DashSource implements IVideoSource {
|
||||
declare class DashSource implements IVideoSource {
|
||||
constructor(obj: DashSourceDef)
|
||||
}
|
||||
|
||||
declare interface IRequest {
|
||||
url: string,
|
||||
headers: Map<string, string>
|
||||
}
|
||||
declare interface IRequestModifierDef {
|
||||
allowByteSkip: boolean
|
||||
}
|
||||
declare class RequestModifier {
|
||||
constructor(obj: IRequestModifierDef) { }
|
||||
|
||||
modifyRequest(url: string, headers: Map<string, string>): IRequest;
|
||||
}
|
||||
|
||||
//Channel
|
||||
interface PlatformChannelDef {
|
||||
declare interface PlatformChannelDef {
|
||||
id: PlatformID,
|
||||
name: string,
|
||||
thumbnail: string,
|
||||
@@ -217,12 +263,29 @@ interface PlatformChannelDef {
|
||||
subscribers: integer,
|
||||
description: string,
|
||||
url: string,
|
||||
urlAlternatives: string[],
|
||||
links: Map<string>?
|
||||
}
|
||||
class PlatformChannel {
|
||||
declare class PlatformChannel {
|
||||
constructor(obj: PlatformChannelDef);
|
||||
}
|
||||
|
||||
//Playlist
|
||||
declare interface PlatformPlaylistDef implements PlatformContent {
|
||||
videoCount: integer,
|
||||
thumbnail: string
|
||||
}
|
||||
declare class PlatformPlaylist extends PlatformContent {
|
||||
constructor(obj: PlatformPlaylistDef);
|
||||
}
|
||||
declare interface PlatformPlaylistDetailsDef implements PlatformPlaylistDef {
|
||||
contents: ContentPager
|
||||
}
|
||||
declare class PlatformPlaylistDetails extends PlatformContent {
|
||||
constructor(obj: PlatformPlaylistDetailsDef);
|
||||
}
|
||||
|
||||
|
||||
//Ratings
|
||||
interface IRating {
|
||||
type: integer
|
||||
@@ -250,7 +313,11 @@ declare class PlatformComment {
|
||||
constructor(obj: CommentDef);
|
||||
}
|
||||
|
||||
declare class PlaybackTracker {
|
||||
constructor(interval: integer);
|
||||
|
||||
setProgress(seconds: integer);
|
||||
}
|
||||
|
||||
declare class LiveEventPager {
|
||||
nextRequest = 4000;
|
||||
@@ -261,8 +328,8 @@ declare class LiveEventPager {
|
||||
nextPage(): LiveEventPager; //Could be self
|
||||
}
|
||||
|
||||
class LiveEvent {
|
||||
type: String
|
||||
declare class LiveEvent {
|
||||
constructor(type: integer);
|
||||
}
|
||||
declare class LiveEventComment extends LiveEvent {
|
||||
constructor(name: string, message: string, thumbnail: string?, colorName: string?, badges: string[]);
|
||||
@@ -287,25 +354,31 @@ declare class ContentPager {
|
||||
constructor(results: PlatformContent[], hasMore: boolean);
|
||||
|
||||
hasMorePagers(): boolean
|
||||
nextPage(): VideoPager; //Could be self
|
||||
nextPage(): ContentPager?; //Could be self
|
||||
}
|
||||
declare class VideoPager {
|
||||
constructor(results: PlatformVideo[], hasMore: boolean);
|
||||
|
||||
hasMorePagers(): boolean
|
||||
nextPage(): VideoPager; //Could be self
|
||||
nextPage(): VideoPager?; //Could be self
|
||||
}
|
||||
declare class ChannelPager {
|
||||
constructor(results: PlatformChannel[], hasMore: boolean);
|
||||
|
||||
hasMorePagers(): boolean;
|
||||
nextPage(): ChannelPager; //Could be self
|
||||
nextPage(): ChannelPager?; //Could be self
|
||||
}
|
||||
declare class PlaylistPager {
|
||||
constructor(results: PlatformPlaylist[], hasMore: boolean);
|
||||
|
||||
hasMorePagers(): boolean;
|
||||
nextPage(): PlaylistPager?;
|
||||
}
|
||||
declare class CommentPager {
|
||||
constructor(results: PlatformComment[], hasMore: boolean);
|
||||
|
||||
hasMorePagers(): boolean
|
||||
nextPage(): CommentPager; //Could be self
|
||||
nextPage(): CommentPager?; //Could be self
|
||||
}
|
||||
|
||||
interface Map<T> {
|
||||
@@ -341,8 +414,9 @@ interface Source {
|
||||
getChannelCapabilities(): ResultCapabilities;
|
||||
|
||||
isContentDetailsUrl(url: string): boolean;
|
||||
getContentDetails(url: string): PlatformVideoDetails;
|
||||
getContentDetails(url: string): PlatformContentDetails;
|
||||
|
||||
//Optional
|
||||
getLiveEvents(url: string): LiveEventPager;
|
||||
|
||||
//Optional
|
||||
|
||||
@@ -78,6 +78,11 @@ class ScriptLoginRequiredException extends ScriptException {
|
||||
super("ScriptLoginRequiredException", msg);
|
||||
}
|
||||
}
|
||||
class LoginRequiredException extends ScriptException {
|
||||
constructor(msg) {
|
||||
super("ScriptLoginRequiredException", msg);
|
||||
}
|
||||
}
|
||||
class CaptchaRequiredException extends Error {
|
||||
constructor(url, body) {
|
||||
super(JSON.stringify({ 'plugin_type': 'CaptchaRequiredException', url, body }));
|
||||
@@ -249,8 +254,8 @@ class PlatformVideoDetails extends PlatformVideo {
|
||||
|
||||
this.description = obj.description ?? "";//String
|
||||
this.video = obj.video ?? {}; //VideoSourceDescriptor
|
||||
this.dash = obj.dash ?? null; //DashSource
|
||||
this.hls = obj.hls ?? null; //HLSSource
|
||||
this.dash = obj.dash ?? null; //DashSource, deprecated
|
||||
this.hls = obj.hls ?? null; //HLSSource, deprecated
|
||||
this.live = obj.live ?? null; //VideoSource
|
||||
|
||||
this.rating = obj.rating ?? null; //IRating
|
||||
@@ -321,6 +326,8 @@ class VideoUrlSource {
|
||||
this.bitrate = obj.bitrate ?? 0;
|
||||
this.duration = obj.duration ?? 0;
|
||||
this.url = obj.url;
|
||||
if(obj.requestModifier)
|
||||
this.requestModifier = obj.requestModifier;
|
||||
}
|
||||
}
|
||||
class VideoUrlRangeSource extends VideoUrlSource {
|
||||
@@ -346,6 +353,17 @@ class AudioUrlSource {
|
||||
this.duration = obj.duration ?? 0;
|
||||
this.url = obj.url;
|
||||
this.language = obj.language ?? Language.UNKNOWN;
|
||||
if(obj.requestModifier)
|
||||
this.requestModifier = obj.requestModifier;
|
||||
}
|
||||
}
|
||||
class AudioUrlWidevineSource extends AudioUrlSource {
|
||||
constructor(obj) {
|
||||
super(obj);
|
||||
this.plugin_type = "AudioUrlWidevineSource";
|
||||
|
||||
this.bearerToken = obj.bearerToken;
|
||||
this.licenseUri = obj.licenseUri;
|
||||
}
|
||||
}
|
||||
class AudioUrlRangeSource extends AudioUrlSource {
|
||||
@@ -371,6 +389,8 @@ class HLSSource {
|
||||
this.priority = obj.priority ?? false;
|
||||
if(obj.language)
|
||||
this.language = obj.language;
|
||||
if(obj.requestModifier)
|
||||
this.requestModifier = obj.requestModifier;
|
||||
}
|
||||
}
|
||||
class DashSource {
|
||||
@@ -382,13 +402,15 @@ class DashSource {
|
||||
this.url = obj.url;
|
||||
if(obj.language)
|
||||
this.language = obj.language;
|
||||
if(obj.requestModifier)
|
||||
this.requestModifier = obj.requestModifier;
|
||||
}
|
||||
}
|
||||
|
||||
class RequestModifier {
|
||||
constructor(obj) {
|
||||
obj = obj ?? {};
|
||||
this.allowByteSkip = obj.allowByteSkip;
|
||||
this.allowByteSkip = obj.allowByteSkip; //Kinda deprecated.. wip
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,7 +436,7 @@ class PlatformPlaylist extends PlatformContent {
|
||||
constructor(obj) {
|
||||
super(obj, 4);
|
||||
this.plugin_type = "PlatformPlaylist";
|
||||
this.videoCount = obj.videoCount ?? 0;
|
||||
this.videoCount = obj.videoCount ?? -1;
|
||||
this.thumbnail = obj.thumbnail;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import java.text.DecimalFormat
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
|
||||
//Long
|
||||
@@ -119,7 +121,8 @@ fun OffsetDateTime.getNowDiffMonths(): Long {
|
||||
return ChronoUnit.MONTHS.between(this, OffsetDateTime.now());
|
||||
}
|
||||
fun OffsetDateTime.getNowDiffYears(): Long {
|
||||
return ChronoUnit.YEARS.between(this, OffsetDateTime.now());
|
||||
val diff = ChronoUnit.MONTHS.between(this, OffsetDateTime.now()) / 12.0;
|
||||
return diff.roundToLong();
|
||||
}
|
||||
|
||||
fun OffsetDateTime.getDiffDays(otherDate: OffsetDateTime): Long {
|
||||
@@ -150,6 +153,7 @@ fun OffsetDateTime.toHumanNowDiffString(abs: Boolean = false) : String {
|
||||
if(value >= secondsInYear) {
|
||||
value = getNowDiffYears();
|
||||
if(abs) value = abs(value);
|
||||
value = Math.max(1, value);
|
||||
unit = "year";
|
||||
}
|
||||
else if(value >= secondsInMonth) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.util.Log
|
||||
import com.google.common.base.CharMatcher
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
@@ -9,7 +10,6 @@ import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.Charset
|
||||
|
||||
|
||||
private const val IPV4_PART_COUNT = 4;
|
||||
@@ -216,15 +216,20 @@ private fun ByteArray.toInetAddress(): InetAddress {
|
||||
}
|
||||
|
||||
fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
||||
val timeout = 2000
|
||||
|
||||
if (addresses.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (addresses.size == 1) {
|
||||
val socket = Socket()
|
||||
|
||||
try {
|
||||
return Socket(addresses[0], port);
|
||||
return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeout) }
|
||||
} catch (e: Throwable) {
|
||||
//Ignored.
|
||||
Log.i("getConnectedSocket", "Failed to connect to: ${addresses[0]}", e)
|
||||
socket.close()
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -249,7 +254,7 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
||||
}
|
||||
}
|
||||
|
||||
socket.connect(InetSocketAddress(address, port));
|
||||
socket.connect(InetSocketAddress(address, port), timeout);
|
||||
|
||||
synchronized(syncObject) {
|
||||
if (connectedSocket == null) {
|
||||
@@ -263,7 +268,7 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
//Ignore
|
||||
Log.i("getConnectedSocket", "Failed to connect to: $address", e)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -50,12 +50,9 @@ fun Protocol.Claim.resolveChannelUrls(): List<String> {
|
||||
|
||||
suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() {
|
||||
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(system))
|
||||
if (!systemState.servers.contains(PolycentricCache.STAGING_SERVER)) {
|
||||
removeServer(PolycentricCache.STAGING_SERVER)
|
||||
}
|
||||
|
||||
if (!systemState.servers.contains(PolycentricCache.SERVER)) {
|
||||
removeServer(PolycentricCache.SERVER)
|
||||
Logger.w("Backfill", "Polycentric prod server not added, adding it.")
|
||||
addServer(PolycentricCache.SERVER)
|
||||
}
|
||||
|
||||
val exceptions = fullyBackfillServers()
|
||||
|
||||
@@ -311,7 +311,10 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 14)
|
||||
var alwaysReloadFromCache: Boolean = false;
|
||||
|
||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 15)
|
||||
@FormField(R.string.peek_channel_contents, FieldForm.TOGGLE, R.string.peek_channel_contents_description, 15)
|
||||
var peekChannelContents: Boolean = false;
|
||||
|
||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 16)
|
||||
fun clearChannelCache() {
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
|
||||
StateCache.instance.clear();
|
||||
@@ -546,6 +549,8 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@DropdownFieldOptionsId(R.array.log_levels)
|
||||
var logLevel: Int = 0;
|
||||
|
||||
fun isVerbose() = logLevel >= 4;
|
||||
|
||||
@FormField(R.string.submit_logs, FieldForm.BUTTON, R.string.submit_logs_to_help_us_narrow_down_issues, 1)
|
||||
fun submitLogs() {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
@@ -685,7 +690,9 @@ class Settings : FragmentedStorageFileJson() {
|
||||
fun manualCheck() {
|
||||
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateUpdate.instance.checkForUpdates(it, true);
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
StateUpdate.instance.checkForUpdates(it, true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
@@ -807,7 +814,36 @@ class Settings : FragmentedStorageFileJson() {
|
||||
var polycentricEnabled: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField(R.string.info, FieldForm.GROUP, -1, 19)
|
||||
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
|
||||
var gestureControls = GestureControls();
|
||||
@Serializable
|
||||
class GestureControls {
|
||||
@FormField(R.string.volume_slider, FieldForm.TOGGLE, R.string.volume_slider_descr, 1)
|
||||
var volumeSlider: Boolean = true;
|
||||
|
||||
@FormField(R.string.brightness_slider, FieldForm.TOGGLE, R.string.brightness_slider_descr, 2)
|
||||
var brightnessSlider: Boolean = true;
|
||||
|
||||
@FormField(R.string.toggle_full_screen, FieldForm.TOGGLE, R.string.toggle_full_screen_descr, 3)
|
||||
var toggleFullscreen: Boolean = true;
|
||||
|
||||
@FormField(R.string.system_brightness, FieldForm.TOGGLE, R.string.system_brightness_descr, 4)
|
||||
var useSystemBrightness: Boolean = false;
|
||||
|
||||
@FormField(R.string.system_volume, FieldForm.TOGGLE, R.string.system_volume_descr, 5)
|
||||
var useSystemVolume: Boolean = true;
|
||||
|
||||
@FormField(R.string.restore_system_brightness, FieldForm.TOGGLE, R.string.restore_system_brightness_descr, 6)
|
||||
var restoreSystemBrightness: Boolean = true;
|
||||
|
||||
@FormField(R.string.zoom_option, FieldForm.TOGGLE, R.string.zoom_option_descr, 7)
|
||||
var zoom: Boolean = true;
|
||||
|
||||
@FormField(R.string.pan_option, FieldForm.TOGGLE, R.string.pan_option_descr, 8)
|
||||
var pan: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField(R.string.info, FieldForm.GROUP, -1, 20)
|
||||
var info = Info();
|
||||
@Serializable
|
||||
class Info {
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.futo.platformplayer.stores.FragmentedStorageFileJson
|
||||
import com.futo.platformplayer.views.fields.ButtonField
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
import com.futo.platformplayer.views.fields.FormFieldWarning
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -493,6 +494,17 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
}
|
||||
|
||||
|
||||
|
||||
@FormField(R.string.networking, FieldForm.GROUP, -1, 18)
|
||||
var networking = Networking();
|
||||
@Serializable
|
||||
class Networking {
|
||||
@FormField(R.string.allow_all_certificates, FieldForm.TOGGLE, -1, 0)
|
||||
@FormFieldWarning(R.string.allow_all_certificates_warning)
|
||||
var allowAllCertificates: Boolean = false;
|
||||
}
|
||||
|
||||
|
||||
@Contextual
|
||||
@Transient
|
||||
@FormField(R.string.info, FieldForm.GROUP, -1, 19)
|
||||
@@ -503,6 +515,8 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
var channelCacheStartupCount = StateCache.instance.channelCacheStartupCount;
|
||||
}
|
||||
|
||||
|
||||
|
||||
//region BOILERPLATE
|
||||
override fun encode(): String {
|
||||
return Json.encodeToString(this);
|
||||
|
||||
@@ -18,6 +18,7 @@ import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||
import com.futo.platformplayer.dialogs.AutomaticBackupDialog
|
||||
@@ -31,12 +32,19 @@ import com.futo.platformplayer.dialogs.ConnectedCastingDialog
|
||||
import com.futo.platformplayer.dialogs.ImportDialog
|
||||
import com.futo.platformplayer.dialogs.ImportOptionsDialog
|
||||
import com.futo.platformplayer.dialogs.MigrateDialog
|
||||
import com.futo.platformplayer.dialogs.PluginUpdateDialog
|
||||
import com.futo.platformplayer.dialogs.ProgressDialog
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImportCache
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateBackup
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.views.ToastView
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -182,6 +190,14 @@ class UIDialogs {
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
fun showPluginUpdateDialog(context: Context, oldConfig: SourcePluginConfig, newConfig: SourcePluginConfig) {
|
||||
val dialog = PluginUpdateDialog(context, oldConfig, newConfig);
|
||||
registerDialogOpened(dialog);
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
|
||||
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action) {
|
||||
val builder = AlertDialog.Builder(context);
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
|
||||
@@ -267,22 +283,48 @@ class UIDialogs {
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
}
|
||||
fun showGeneralRetryErrorDialog(context: Context, msg: String, ex: Throwable? = null, retryAction: (() -> Unit)? = null, closeAction: (() -> Unit)? = null) {
|
||||
fun showGeneralRetryErrorDialog(context: Context, msg: String, ex: Throwable? = null, retryAction: (() -> Unit)? = null, closeAction: (() -> Unit)? = null, mainFragment: MainFragment? = null) {
|
||||
val pluginConfig = if(ex is PluginException) ex.config else null;
|
||||
val pluginInfo = if(ex is PluginException)
|
||||
"\nPlugin [${ex.config.name}]" else "";
|
||||
showDialog(context,
|
||||
R.drawable.ic_error_pred,
|
||||
"${msg}${pluginInfo}",
|
||||
(if(ex != null ) "${ex.message}" else ""),
|
||||
if(ex is PluginException) ex.code else null,
|
||||
0,
|
||||
UIDialogs.Action(context.getString(R.string.retry), {
|
||||
retryAction?.invoke();
|
||||
}, UIDialogs.ActionStyle.PRIMARY),
|
||||
UIDialogs.Action(context.getString(R.string.close), {
|
||||
closeAction?.invoke()
|
||||
}, UIDialogs.ActionStyle.NONE)
|
||||
);
|
||||
|
||||
var exMsg = if(ex != null ) "${ex.message}" else "";
|
||||
if(pluginConfig != null && pluginConfig is SourcePluginConfig && StatePlugins.instance.hasUpdateAvailable(pluginConfig))
|
||||
exMsg += "\n\nAn update is available"
|
||||
|
||||
if(mainFragment != null && pluginConfig != null && pluginConfig is SourcePluginConfig && StatePlugins.instance.hasUpdateAvailable(pluginConfig))
|
||||
showDialog(context,
|
||||
R.drawable.ic_error_pred,
|
||||
"${msg}${pluginInfo}",
|
||||
exMsg,
|
||||
if(ex is PluginException) ex.code else null,
|
||||
1,
|
||||
UIDialogs.Action(context.getString(R.string.update), {
|
||||
mainFragment.navigate<SourceDetailFragment>(SourceDetailFragment.UpdatePluginAction(pluginConfig));
|
||||
if(mainFragment is VideoDetailFragment)
|
||||
mainFragment.minimizeVideoDetail();
|
||||
}, UIDialogs.ActionStyle.ACCENT),
|
||||
UIDialogs.Action(context.getString(R.string.close), {
|
||||
closeAction?.invoke()
|
||||
}, UIDialogs.ActionStyle.NONE),
|
||||
UIDialogs.Action(context.getString(R.string.retry), {
|
||||
retryAction?.invoke();
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
else
|
||||
showDialog(context,
|
||||
R.drawable.ic_error_pred,
|
||||
"${msg}${pluginInfo}",
|
||||
exMsg,
|
||||
if(ex is PluginException) ex.code else null,
|
||||
0,
|
||||
UIDialogs.Action(context.getString(R.string.close), {
|
||||
closeAction?.invoke()
|
||||
}, UIDialogs.ActionStyle.NONE),
|
||||
UIDialogs.Action(context.getString(R.string.retry), {
|
||||
retryAction?.invoke();
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
}
|
||||
|
||||
fun showSingleButtonDialog(context: Context, icon: Int, text: String, buttonText: String, action: (() -> Unit)) {
|
||||
@@ -303,12 +345,16 @@ class UIDialogs {
|
||||
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
|
||||
}
|
||||
|
||||
fun showUpdateAvailableDialog(context: Context, lastVersion: Int) {
|
||||
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
|
||||
val dialog = AutoUpdateDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
dialog.setMaxVersion(lastVersion);
|
||||
|
||||
if (hideExceptionButtons) {
|
||||
dialog.hideExceptionButtons()
|
||||
}
|
||||
}
|
||||
|
||||
fun showChangelogDialog(context: Context, lastVersion: Int) {
|
||||
@@ -338,8 +384,8 @@ class UIDialogs {
|
||||
}
|
||||
}
|
||||
|
||||
fun showImportDialog(context: Context, store: ManagedStore<*>, name: String, reconstructions: List<String>, onConcluded: () -> Unit) {
|
||||
val dialog = ImportDialog(context, store, name, reconstructions, onConcluded);
|
||||
fun showImportDialog(context: Context, store: ManagedStore<*>, name: String, reconstructions: List<String>, cache: ImportCache?, onConcluded: () -> Unit) {
|
||||
val dialog = ImportDialog(context, store, name, reconstructions, cache, onConcluded);
|
||||
registerDialogOpened(dialog);
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
@@ -398,13 +444,28 @@ class UIDialogs {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
try {
|
||||
StateApp.withContext {
|
||||
Toast.makeText(it, text, if (long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show();
|
||||
toast(it, text, long);
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to show toast.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
fun appToast(text: String, long: Boolean = false) {
|
||||
appToast(ToastView.Toast(text, long))
|
||||
}
|
||||
fun appToastError(text: String, long: Boolean) {
|
||||
StateApp.withContext {
|
||||
appToast(ToastView.Toast(text, long, it.getColor(R.color.pastel_red)));
|
||||
};
|
||||
}
|
||||
fun appToast(toast: ToastView.Toast) {
|
||||
StateApp.withContext {
|
||||
if(it is MainActivity) {
|
||||
it.showAppToast(toast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showClickableToast(context: Context, text: String, onClick: () -> Unit, isLongDuration: Boolean = false) {
|
||||
//TODO: Is not actually clickable...
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.NotificationManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
@@ -38,11 +38,17 @@ import com.futo.platformplayer.states.StateMeta
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.states.StateSubscriptionGroups
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.views.AnyAdapterView
|
||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||
import com.futo.platformplayer.views.LoaderView
|
||||
import com.futo.platformplayer.views.adapters.viewholders.SubscriptionGroupBarViewHolder
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuFilters
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuRecycler
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput
|
||||
import com.futo.platformplayer.views.pills.RoundButton
|
||||
import com.futo.platformplayer.views.pills.RoundButtonGroup
|
||||
@@ -68,7 +74,7 @@ class UISlideOverlays {
|
||||
return menu;
|
||||
}
|
||||
|
||||
fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup) {
|
||||
fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup): SlideUpMenuOverlay {
|
||||
val items = arrayListOf<View>();
|
||||
|
||||
val originalNotif = subscription.doNotifications;
|
||||
@@ -77,20 +83,48 @@ class UISlideOverlays {
|
||||
val originalVideo = subscription.doFetchVideos;
|
||||
val originalPosts = subscription.doFetchPosts;
|
||||
|
||||
val menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, listOf());
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
|
||||
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
|
||||
val capabilities = plugin.getChannelCapabilities();
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
var menu: SlideUpMenuOverlay? = null;
|
||||
|
||||
|
||||
items.addAll(listOf(
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", {
|
||||
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
|
||||
}, false),
|
||||
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
|
||||
SlideUpMenuGroup(container.context, "Subscription Groups",
|
||||
"You can select which groups this subscription is part of.",
|
||||
-1, listOf()) else null,
|
||||
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
|
||||
SlideUpMenuRecycler(container.context, "as") {
|
||||
val groups = ArrayList<SubscriptionGroup>(StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
|
||||
.sortedBy { !it.selected });
|
||||
var adapter: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>? = null;
|
||||
adapter = it.asAny(groups, RecyclerView.HORIZONTAL) {
|
||||
it.onClick.subscribe {
|
||||
if(it is SubscriptionGroup.Selectable) {
|
||||
val actualGroup = StateSubscriptionGroups.instance.getSubscriptionGroup(it.id)
|
||||
?: return@subscribe;
|
||||
groups.clear();
|
||||
if(it.selected)
|
||||
actualGroup.urls.remove(subscription.channel.url);
|
||||
else
|
||||
actualGroup.urls.add(subscription.channel.url);
|
||||
|
||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(actualGroup);
|
||||
groups.addAll(StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
|
||||
.sortedBy { !it.selected });
|
||||
adapter?.notifyContentChanged();
|
||||
}
|
||||
}
|
||||
};
|
||||
return@SlideUpMenuRecycler adapter;
|
||||
} else null,
|
||||
SlideUpMenuGroup(container.context, "Fetch Settings",
|
||||
"Depending on the platform you might not need to enable a type for it to be available.",
|
||||
-1, listOf()),
|
||||
@@ -119,7 +153,7 @@ class UISlideOverlays {
|
||||
}, false)*/
|
||||
).filterNotNull());
|
||||
|
||||
menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items);
|
||||
menu.setItems(items);
|
||||
|
||||
if(subscription.doNotifications)
|
||||
menu.selectOption(null, "notifications", true, true);
|
||||
@@ -174,6 +208,8 @@ class UISlideOverlays {
|
||||
menu.show();
|
||||
}
|
||||
}
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
fun showAddToGroupOverlay(channel: IPlatformVideo, container: ViewGroup) {
|
||||
@@ -343,7 +379,7 @@ class UISlideOverlays {
|
||||
videoSources.filter { it is IVideoUrlSource && it.isDownloadable() }.asIterable(),
|
||||
Settings.instance.downloads.getDefaultVideoQualityPixels(),
|
||||
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
|
||||
) as IVideoUrlSource;
|
||||
) as IVideoUrlSource?;
|
||||
}
|
||||
|
||||
if (audioSources != null) {
|
||||
@@ -474,10 +510,15 @@ class UISlideOverlays {
|
||||
}
|
||||
}
|
||||
fun showDownloadPlaylistOverlay(playlist: Playlist, container: ViewGroup) {
|
||||
showUnknownVideoDownload(container.context.getString(R.string.video), container) { px, bitrate ->
|
||||
showUnknownVideoDownload(container.context.getString(R.string.playlist), container) { px, bitrate ->
|
||||
StateDownloads.instance.download(playlist, px, bitrate);
|
||||
};
|
||||
}
|
||||
fun showDownloadWatchlaterOverlay(container: ViewGroup) {
|
||||
showUnknownVideoDownload(container.context.getString(R.string.watch_later), container, { px, bitrate ->
|
||||
StateDownloads.instance.downloadWatchLater(px, bitrate);
|
||||
})
|
||||
}
|
||||
private fun showUnknownVideoDownload(toDownload: String, container: ViewGroup, cb: (Long?, Long?)->Unit) {
|
||||
val items = arrayListOf<View>();
|
||||
var menu: SlideUpMenuOverlay? = null;
|
||||
@@ -647,9 +688,17 @@ class UISlideOverlays {
|
||||
val watchLater = StatePlaylists.instance.getWatchLater();
|
||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
|
||||
(listOf(
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), container.context.getString(R.string.download), {
|
||||
showDownloadVideoOverlay(video, container, true);
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), "download", {
|
||||
showDownloadVideoOverlay(video, container, true);
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_share, container.context.getString(R.string.share), "Share the video", "share", {
|
||||
val url = if(video.shareUrl.isNotEmpty()) video.shareUrl else video.url;
|
||||
container.context.startActivity(Intent.createChooser(Intent().apply {
|
||||
action = Intent.ACTION_SEND;
|
||||
putExtra(Intent.EXTRA_TEXT, url);
|
||||
type = "text/plain";
|
||||
}, null));
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, container.context.getString(R.string.hide_creator_from_home), "", "hide_creator", {
|
||||
StateMeta.instance.addHiddenCreator(video.author.url);
|
||||
UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
|
||||
@@ -687,7 +736,7 @@ class UISlideOverlays {
|
||||
}
|
||||
|
||||
|
||||
fun showAddToOverlay(video: IPlatformVideo, container: ViewGroup): SlideUpMenuOverlay {
|
||||
fun showAddToOverlay(video: IPlatformVideo, container: ViewGroup, slideUpMenuOverlayUpdated: (SlideUpMenuOverlay) -> Unit): SlideUpMenuOverlay {
|
||||
|
||||
val items = arrayListOf<View>();
|
||||
|
||||
@@ -718,6 +767,13 @@ class UISlideOverlays {
|
||||
);
|
||||
|
||||
val playlistItems = arrayListOf<SlideUpMenuItem>();
|
||||
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, container.context.getString(R.string.new_playlist), container.context.getString(R.string.add_to_new_playlist), "add_to_new_playlist", {
|
||||
slideUpMenuOverlayUpdated(showCreatePlaylistOverlay(container) {
|
||||
val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
|
||||
});
|
||||
}, false))
|
||||
|
||||
for (playlist in allPlaylists) {
|
||||
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, playlist.name, "${playlist.videos.size} " + container.context.getString(R.string.videos), "",
|
||||
{
|
||||
@@ -732,8 +788,8 @@ class UISlideOverlays {
|
||||
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() };
|
||||
}
|
||||
|
||||
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>): SlideUpMenuFilters {
|
||||
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues);
|
||||
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>, isChannelSearch: Boolean = false): SlideUpMenuFilters {
|
||||
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues, isChannelSearch);
|
||||
overlay.show();
|
||||
return overlay;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
@@ -37,8 +38,10 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var _sourceHeader: SourceHeaderView;
|
||||
|
||||
|
||||
private lateinit var _sourcePermissions: LinearLayout;
|
||||
private lateinit var _sourceWarnings: LinearLayout;
|
||||
private lateinit var _sourceWarningsContainer: LinearLayout;
|
||||
|
||||
private lateinit var _container: ScrollView;
|
||||
private lateinit var _loader: ImageView;
|
||||
@@ -79,6 +82,7 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
|
||||
_sourcePermissions = findViewById(R.id.source_permissions);
|
||||
_sourceWarnings = findViewById(R.id.source_warnings);
|
||||
_sourceWarningsContainer = findViewById(R.id.container_source_warnings);
|
||||
|
||||
_container = findViewById(R.id.configContainer);
|
||||
_loader = findViewById(R.id.loader);
|
||||
@@ -203,21 +207,30 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
|
||||
val pastelRed = ContextCompat.getColor(this, R.color.pastel_red);
|
||||
|
||||
for(warning in config.getWarnings(script))
|
||||
val warnings = config.getWarnings(script);
|
||||
for(warning in warnings)
|
||||
_sourceWarnings.addView(
|
||||
SourceInfoView(this,
|
||||
R.drawable.ic_security_pred,
|
||||
warning.first,
|
||||
warning.second)
|
||||
.withDescriptionColor(pastelRed));
|
||||
_sourceWarningsContainer.isVisible = warnings.isNotEmpty();
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
fun install(config: SourcePluginConfig, script: String) {
|
||||
val isNew = !StatePlatform.instance.getAvailableClients().any { it.id == config.id };
|
||||
StatePlugins.instance.installPlugin(this, lifecycleScope, config, script) {
|
||||
if(it)
|
||||
if(it) {
|
||||
StatePlugins.instance.clearUpdateAvailable(config)
|
||||
if(isNew)
|
||||
lifecycleScope.launch {
|
||||
StatePlatform.instance.enableClient(listOf(config.id));
|
||||
}
|
||||
backToSources();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
||||
lateinit var _buttonBack: ImageButton;
|
||||
|
||||
lateinit var _buttonQR: BigButton;
|
||||
lateinit var _buttonBrowse: BigButton;
|
||||
lateinit var _buttonURL: BigButton;
|
||||
lateinit var _buttonPlugins: BigButton;
|
||||
|
||||
@@ -56,6 +57,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
||||
_buttonBack = findViewById(R.id.button_back);
|
||||
|
||||
_buttonQR = findViewById(R.id.option_qr);
|
||||
_buttonBrowse = findViewById(R.id.option_browse);
|
||||
_buttonURL = findViewById(R.id.option_url);
|
||||
_buttonPlugins = findViewById(R.id.option_plugins);
|
||||
|
||||
@@ -74,6 +76,9 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
||||
integrator.setCaptureActivity(QRCaptureActivity::class.java);
|
||||
_qrCodeResultLauncher.launch(integrator.createScanIntent())
|
||||
}
|
||||
_buttonBrowse.onClick.subscribe {
|
||||
startActivity(MainActivity.getTabIntent(this, "BROWSE_PLUGINS"));
|
||||
}
|
||||
|
||||
_buttonURL.onClick.subscribe {
|
||||
UIDialogs.toast(this, getString(R.string.not_implemented_yet));
|
||||
|
||||
@@ -4,15 +4,19 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.logging.LogLevel
|
||||
import com.futo.platformplayer.logging.Logging
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateUpdate
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -26,6 +30,7 @@ class ExceptionActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonSubmit: LinearLayout;
|
||||
private lateinit var _buttonRestart: LinearLayout;
|
||||
private lateinit var _buttonClose: LinearLayout;
|
||||
private lateinit var _buttonCheckForUpdates: LinearLayout;
|
||||
private var _file: File? = null;
|
||||
private var _submitted = false;
|
||||
|
||||
@@ -43,6 +48,7 @@ class ExceptionActivity : AppCompatActivity() {
|
||||
_buttonSubmit = findViewById(R.id.button_submit);
|
||||
_buttonRestart = findViewById(R.id.button_restart);
|
||||
_buttonClose = findViewById(R.id.button_close);
|
||||
_buttonCheckForUpdates = findViewById(R.id.button_check_for_updates);
|
||||
|
||||
val context = intent.getStringExtra(EXTRA_CONTEXT) ?: getString(R.string.unknown_context);
|
||||
val stack = intent.getStringExtra(EXTRA_STACK) ?: getString(R.string.something_went_wrong_missing_stack_trace);
|
||||
@@ -81,6 +87,17 @@ class ExceptionActivity : AppCompatActivity() {
|
||||
_buttonClose.setOnClickListener {
|
||||
finish();
|
||||
};
|
||||
|
||||
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||
_buttonCheckForUpdates.visibility = View.VISIBLE
|
||||
_buttonCheckForUpdates.setOnClickListener {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
StateUpdate.instance.checkForUpdates(this@ExceptionActivity, true, true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_buttonCheckForUpdates.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun submitFile() {
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
@@ -39,6 +40,7 @@ class LoginActivity : AppCompatActivity() {
|
||||
_textUrl = findViewById(R.id.text_url);
|
||||
_buttonClose = findViewById(R.id.button_close);
|
||||
_buttonClose.setOnClickListener {
|
||||
UIDialogs.toast("Login cancelled", false);
|
||||
finish();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
@@ -24,11 +23,13 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentContainerView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
@@ -40,11 +41,14 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFrag
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
||||
import com.futo.platformplayer.listeners.OrientationManager
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImportCache
|
||||
import com.futo.platformplayer.models.UrlVideoWithTime
|
||||
import com.futo.platformplayer.states.*
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.SubscriptionStorage
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.views.ToastView
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.google.gson.JsonParser
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import kotlinx.coroutines.*
|
||||
@@ -54,6 +58,7 @@ import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
@@ -65,6 +70,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
lateinit var rootView : MotionLayout;
|
||||
|
||||
private lateinit var _overlayContainer: FrameLayout;
|
||||
private lateinit var _toastView: ToastView;
|
||||
|
||||
//Segment Containers
|
||||
private lateinit var _fragContainerTopBar: FragmentContainerView;
|
||||
@@ -98,6 +104,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
lateinit var _fragMainTutorial: TutorialFragment;
|
||||
lateinit var _fragMainPlaylists: PlaylistsFragment;
|
||||
lateinit var _fragMainPlaylist: PlaylistFragment;
|
||||
lateinit var _fragMainRemotePlaylist: RemotePlaylistFragment;
|
||||
lateinit var _fragWatchlist: WatchLaterFragment;
|
||||
lateinit var _fragHistory: HistoryFragment;
|
||||
lateinit var _fragSourceDetail: SourceDetailFragment;
|
||||
@@ -138,7 +145,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
|
||||
try {
|
||||
handleUrlAll(content)
|
||||
runBlocking {
|
||||
handleUrlAll(content)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to handle URL.", e)
|
||||
UIDialogs.toast(this, "Failed to handle URL: ${e.message}")
|
||||
@@ -147,6 +156,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
|
||||
constructor() : super() {
|
||||
ApiMethods.UserAgent = "Grayjay Android (${BuildConfig.VERSION_CODE})";
|
||||
|
||||
Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
|
||||
val writer = StringWriter();
|
||||
|
||||
@@ -185,6 +196,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Logger.i(TAG, "MainActivity Starting");
|
||||
StateApp.instance.setGlobalContext(this, lifecycleScope);
|
||||
StateApp.instance.mainAppStarting(this);
|
||||
|
||||
@@ -207,7 +219,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragContainerVideoDetail = findViewById(R.id.fragment_overlay);
|
||||
_fragContainerOverlay = findViewById(R.id.fragment_overlay_container);
|
||||
_overlayContainer = findViewById(R.id.overlay_container);
|
||||
//_overlayContainer.visibility = View.GONE;
|
||||
_toastView = findViewById(R.id.toast_view);
|
||||
|
||||
//Initialize fragments
|
||||
|
||||
@@ -235,6 +247,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragMainSources = SourcesFragment.newInstance();
|
||||
_fragMainPlaylists = PlaylistsFragment.newInstance();
|
||||
_fragMainPlaylist = PlaylistFragment.newInstance();
|
||||
_fragMainRemotePlaylist = RemotePlaylistFragment.newInstance();
|
||||
_fragPostDetail = PostDetailFragment.newInstance();
|
||||
_fragWatchlist = WatchLaterFragment.newInstance();
|
||||
_fragHistory = HistoryFragment.newInstance();
|
||||
@@ -320,6 +333,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragMainSources.topBar = _fragTopBarAdd;
|
||||
_fragMainPlaylists.topBar = _fragTopBarGeneral;
|
||||
_fragMainPlaylist.topBar = _fragTopBarNavigation;
|
||||
_fragMainRemotePlaylist.topBar = _fragTopBarNavigation;
|
||||
_fragPostDetail.topBar = _fragTopBarNavigation;
|
||||
_fragWatchlist.topBar = _fragTopBarNavigation;
|
||||
_fragHistory.topBar = _fragTopBarNavigation;
|
||||
@@ -478,21 +492,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
|
||||
_isVisible = true;
|
||||
val videoToOpen = StateSaved.instance.videoToOpen;
|
||||
|
||||
if (_wasStopped) {
|
||||
_wasStopped = false;
|
||||
|
||||
if (videoToOpen != null && _fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
||||
Logger.i(TAG, "onResume videoToOpen=$videoToOpen");
|
||||
if (StatePlatform.instance.hasEnabledVideoClient(videoToOpen.url)) {
|
||||
navigate(_fragVideoDetail, UrlVideoWithTime(videoToOpen.url, videoToOpen.timeSeconds, false));
|
||||
_fragVideoDetail.maximizeVideoDetail(true);
|
||||
}
|
||||
|
||||
StateSaved.instance.setVideoToOpenNonBlocking(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
@@ -547,13 +546,28 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
navigate(_fragMainSources);
|
||||
}
|
||||
};
|
||||
"BROWSE_PLUGINS" -> {
|
||||
navigate(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
|
||||
Pair("grayjay") { req ->
|
||||
StateApp.instance.contextOrNull?.let {
|
||||
if(it is MainActivity) {
|
||||
runBlocking {
|
||||
it.handleUrlAll(req.url.toString());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (targetData != null) {
|
||||
handleUrlAll(targetData)
|
||||
runBlocking {
|
||||
handleUrlAll(targetData)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
@@ -561,7 +575,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
fun handleUrlAll(url: String) {
|
||||
suspend fun handleUrlAll(url: String) {
|
||||
val uri = Uri.parse(url)
|
||||
when (uri.scheme) {
|
||||
"grayjay" -> {
|
||||
@@ -597,7 +611,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
UIDialogs.showSingleButtonDialog(
|
||||
this,
|
||||
R.drawable.ic_play,
|
||||
getString(R.string.unknown_content_format) + " [${url}]",
|
||||
getString(R.string.unknown_content_format) + " [${url}]\n[${intent.type}]",
|
||||
"Ok",
|
||||
{ });
|
||||
}
|
||||
@@ -645,23 +659,38 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
fun handleUrl(url: String): Boolean {
|
||||
suspend fun handleUrl(url: String): Boolean {
|
||||
Logger.i(TAG, "handleUrl(url=$url)")
|
||||
|
||||
if (StatePlatform.instance.hasEnabledVideoClient(url)) {
|
||||
navigate(_fragVideoDetail, url);
|
||||
_fragVideoDetail.maximizeVideoDetail(true);
|
||||
return true;
|
||||
} else if(StatePlatform.instance.hasEnabledChannelClient(url)) {
|
||||
navigate(_fragMainChannel, url);
|
||||
return withContext(Dispatchers.IO) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) on IO");
|
||||
if (StatePlatform.instance.hasEnabledVideoClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found video client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
navigate(_fragVideoDetail, url);
|
||||
|
||||
lifecycleScope.launch {
|
||||
delay(100);
|
||||
_fragVideoDetail.minimizeVideoDetail();
|
||||
};
|
||||
return true;
|
||||
_fragVideoDetail.maximizeVideoDetail(true);
|
||||
}
|
||||
return@withContext true;
|
||||
} else if (StatePlatform.instance.hasEnabledChannelClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found channel client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
navigate(_fragMainChannel, url);
|
||||
delay(100);
|
||||
_fragVideoDetail.minimizeVideoDetail();
|
||||
};
|
||||
return@withContext true;
|
||||
} else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found playlist client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
navigate(_fragMainPlaylist, url);
|
||||
delay(100);
|
||||
_fragVideoDetail.minimizeVideoDetail();
|
||||
};
|
||||
return@withContext true;
|
||||
}
|
||||
return@withContext false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
fun handleContent(file: String, mime: String? = null): Boolean {
|
||||
Logger.i(TAG, "handleContent(url=$file)");
|
||||
@@ -672,10 +701,22 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
if(!recon.trim().startsWith("["))
|
||||
return handleUnknownJson(recon);
|
||||
|
||||
val reconLines = Json.decodeFromString<List<String>>(recon);
|
||||
var reconLines = Json.decodeFromString<List<String>>(recon);
|
||||
val cacheStr = reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length);
|
||||
reconLines = reconLines.filter { !it.startsWith("__CACHE:") }; //TODO: constant prefix
|
||||
var cache: ImportCache? = null;
|
||||
try {
|
||||
if(cacheStr != null)
|
||||
cache = Json.decodeFromString(cacheStr);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to deserialize cache");
|
||||
}
|
||||
|
||||
|
||||
recon = reconLines.joinToString("\n");
|
||||
Logger.i(TAG, "Opened shared playlist reconstruction\n${recon}");
|
||||
handleReconstruction(recon);
|
||||
handleReconstruction(recon, cache);
|
||||
return true;
|
||||
}
|
||||
else if(file.lowercase().endsWith(".zip") || mime == "application/zip") {
|
||||
@@ -690,12 +731,25 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
fun handleFile(file: String): Boolean {
|
||||
Logger.i(TAG, "handleFile(url=$file)");
|
||||
if(file.lowercase().endsWith(".json")) {
|
||||
val recon = String(readSharedFile(file));
|
||||
var recon = String(readSharedFile(file));
|
||||
if(!recon.startsWith("["))
|
||||
return handleUnknownJson(recon);
|
||||
|
||||
var reconLines = Json.decodeFromString<List<String>>(recon);
|
||||
val cacheStr = reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length);
|
||||
reconLines = reconLines.filter { !it.startsWith("__CACHE:") }; //TODO: constant prefix
|
||||
var cache: ImportCache? = null;
|
||||
try {
|
||||
if(cacheStr != null)
|
||||
cache = Json.decodeFromString(cacheStr);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to deserialize cache");
|
||||
}
|
||||
recon = reconLines.joinToString("\n");
|
||||
|
||||
Logger.i(TAG, "Opened shared playlist reconstruction\n${recon}");
|
||||
handleReconstruction(recon);
|
||||
handleReconstruction(recon, cache);
|
||||
return true;
|
||||
}
|
||||
else if(file.lowercase().endsWith(".zip")) {
|
||||
@@ -707,7 +761,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
fun handleReconstruction(recon: String) {
|
||||
fun handleReconstruction(recon: String, cache: ImportCache? = null) {
|
||||
val type = ManagedStore.getReconstructionIdentifier(recon);
|
||||
val store: ManagedStore<*> = when(type) {
|
||||
"Playlist" -> StatePlaylists.instance.playlistStore
|
||||
@@ -724,7 +778,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
|
||||
if(!type.isNullOrEmpty()) {
|
||||
UIDialogs.showImportDialog(this, store, name, listOf(recon)) {
|
||||
UIDialogs.showImportDialog(this, store, name, listOf(recon), cache) {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -814,11 +868,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
if(_fragBotBarMenu.onBackPressed())
|
||||
return;
|
||||
|
||||
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED &&
|
||||
_fragVideoDetail.onBackPressed())
|
||||
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
|
||||
return;
|
||||
|
||||
|
||||
if(!fragCurrent.onBackPressed())
|
||||
closeSegment();
|
||||
}
|
||||
@@ -864,7 +916,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_orientationManager.disable();
|
||||
|
||||
StateApp.instance.mainAppDestroyed(this);
|
||||
StateSaved.instance.setVideoToOpenBlocking(null);
|
||||
}
|
||||
|
||||
inline fun <reified T> isFragmentActive(): Boolean {
|
||||
@@ -996,6 +1047,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
SourcesFragment::class -> _fragMainSources as T;
|
||||
PlaylistsFragment::class -> _fragMainPlaylists as T;
|
||||
PlaylistFragment::class -> _fragMainPlaylist as T;
|
||||
RemotePlaylistFragment::class -> _fragMainRemotePlaylist as T;
|
||||
PostDetailFragment::class -> _fragPostDetail as T;
|
||||
WatchLaterFragment::class -> _fragWatchlist as T;
|
||||
HistoryFragment::class -> _fragHistory as T;
|
||||
@@ -1052,6 +1104,43 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
private val _toastQueue = ConcurrentLinkedQueue<ToastView.Toast>();
|
||||
private var _toastJob: Job? = null;
|
||||
fun showAppToast(toast: ToastView.Toast) {
|
||||
synchronized(_toastQueue) {
|
||||
_toastQueue.add(toast);
|
||||
if(_toastJob?.isActive != true)
|
||||
_toastJob = lifecycleScope.launch(Dispatchers.Default) {
|
||||
launchAppToastJob();
|
||||
};
|
||||
}
|
||||
}
|
||||
private suspend fun launchAppToastJob() {
|
||||
Logger.i(TAG, "Starting appToast loop");
|
||||
while(!_toastQueue.isEmpty()) {
|
||||
val toast = _toastQueue.poll() ?: continue;
|
||||
Logger.i(TAG, "Showing next toast (${toast.msg})");
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
if (!_toastView.isVisible) {
|
||||
Logger.i(TAG, "First showing toast");
|
||||
_toastView.setToast(toast);
|
||||
_toastView.show(true);
|
||||
} else {
|
||||
_toastView.setToastAnimated(toast);
|
||||
}
|
||||
}
|
||||
if(toast.long)
|
||||
delay(5000);
|
||||
else
|
||||
delay(3000);
|
||||
}
|
||||
Logger.i(TAG, "Ending appToast loop");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
_toastView.hide(true) {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//TODO: Only calls last handler due to missing request codes on ActivityResultLaunchers.
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
@@ -19,7 +20,12 @@ import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.polycentric.core.*
|
||||
import com.futo.polycentric.core.ContentType
|
||||
import com.futo.polycentric.core.SignedEvent
|
||||
import com.futo.polycentric.core.StorageTypeCRDTItem
|
||||
import com.futo.polycentric.core.StorageTypeCRDTSetItem
|
||||
import com.futo.polycentric.core.Store
|
||||
import com.futo.polycentric.core.toBase64Url
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.MultiFormatWriter
|
||||
import com.google.zxing.common.BitMatrix
|
||||
@@ -64,11 +70,8 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
_buttonShare.onClick.subscribe {
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain";
|
||||
putExtra(Intent.EXTRA_TEXT, _exportBundle);
|
||||
}
|
||||
startActivity(Intent.createChooser(shareIntent, getString(R.string.share_text)));
|
||||
val shareIntent = Intent(Intent.ACTION_VIEW, Uri.parse(_exportBundle))
|
||||
startActivity(Intent.createChooser(shareIntent, "Share ID"));
|
||||
};
|
||||
|
||||
_buttonCopy.onClick.subscribe {
|
||||
|
||||
+10
-3
@@ -12,14 +12,14 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.polycentric.PolycentricStorage
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.polycentric.core.ProcessHandle
|
||||
import com.futo.polycentric.core.Store
|
||||
import com.futo.polycentric.core.Synchronization
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -71,7 +71,14 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
|
||||
try {
|
||||
processHandle = ProcessHandle.create();
|
||||
Store.instance.addProcessSecret(processHandle.processSecret);
|
||||
processHandle.addServer("https://srv1-stg.polycentric.io");
|
||||
|
||||
try {
|
||||
PolycentricStorage.instance.addProcessSecret(processHandle.processSecret)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
|
||||
}
|
||||
|
||||
processHandle.addServer(PolycentricCache.SERVER);
|
||||
processHandle.setUsername(username);
|
||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
||||
} catch (e: Throwable) {
|
||||
|
||||
+7
@@ -13,6 +13,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.polycentric.PolycentricStorage
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
@@ -126,6 +127,12 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
val processSecret = ProcessSecret(keyPair, Process.random());
|
||||
Store.instance.addProcessSecret(processSecret);
|
||||
|
||||
try {
|
||||
PolycentricStorage.instance.addProcessSecret(processSecret)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
|
||||
}
|
||||
|
||||
val processHandle = processSecret.toProcessHandle();
|
||||
|
||||
for (e in exportBundle.events.eventsList) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.futo.platformplayer.api.http
|
||||
|
||||
import androidx.collection.arrayMapOf
|
||||
import com.futo.platformplayer.SettingsDev
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.ensureNotMainThread
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -13,6 +15,11 @@ import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
open class ManagedHttpClient {
|
||||
@@ -25,8 +32,29 @@ open class ManagedHttpClient {
|
||||
|
||||
var user_agent = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"
|
||||
|
||||
private val trustAllCerts = arrayOf<TrustManager>(
|
||||
object: X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
|
||||
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> {
|
||||
return arrayOf();
|
||||
}
|
||||
}
|
||||
);
|
||||
private fun trustAllCertificates(builder: OkHttpClient.Builder) {
|
||||
val sslContext = SSLContext.getInstance("SSL");
|
||||
sslContext.init(null, trustAllCerts, SecureRandom());
|
||||
builder.sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager);
|
||||
builder.hostnameVerifier { a, b ->
|
||||
return@hostnameVerifier true;
|
||||
}
|
||||
Logger.w(TAG, "Creating INSECURE client (TrustAll)");
|
||||
}
|
||||
|
||||
constructor(builder: OkHttpClient.Builder = OkHttpClient.Builder()) {
|
||||
_builderTemplate = builder;
|
||||
if(SettingsDev.instance.developerMode && SettingsDev.instance.networking.allowAllCertificates)
|
||||
trustAllCertificates(builder);
|
||||
client = builder.addNetworkInterceptor { chain ->
|
||||
val request = beforeRequest(chain.request());
|
||||
val response = afterRequest(chain.proceed(request));
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
package com.futo.platformplayer.api.media
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
|
||||
/**
|
||||
* A temporary class that caches video results
|
||||
* In future this should be part of a bigger system
|
||||
*/
|
||||
class CachedPlatformClient : IPlatformClient {
|
||||
private val _client : IPlatformClient;
|
||||
override val id: String get() = _client.id;
|
||||
override val name: String get() = _client.name;
|
||||
override val icon: ImageVariable? get() = _client.icon;
|
||||
|
||||
private val _cache: LruCache<String, IPlatformContentDetails>;
|
||||
|
||||
override val capabilities: PlatformClientCapabilities
|
||||
get() = _client.capabilities;
|
||||
|
||||
constructor(client : IPlatformClient, cacheSize : Int = 10 * 1024 * 1024) {
|
||||
this._client = client;
|
||||
this._cache = LruCache<String, IPlatformContentDetails>(cacheSize);
|
||||
}
|
||||
override fun initialize() { _client.initialize() }
|
||||
override fun disable() { _client.disable() }
|
||||
|
||||
override fun isContentDetailsUrl(url: String): Boolean = _client.isContentDetailsUrl(url);
|
||||
override fun getContentDetails(url: String): IPlatformContentDetails {
|
||||
var result = _cache.get(url);
|
||||
if(result == null) {
|
||||
result = _client.getContentDetails(url);
|
||||
_cache.put(url, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
override fun getContentChapters(url: String): List<IChapter> = _client.getContentChapters(url);
|
||||
override fun getPlaybackTracker(url: String): IPlaybackTracker? = _client.getPlaybackTracker(url);
|
||||
|
||||
override fun isChannelUrl(url: String): Boolean = _client.isChannelUrl(url);
|
||||
override fun getChannel(channelUrl: String): IPlatformChannel = _client.getChannel(channelUrl);
|
||||
|
||||
override fun getChannelCapabilities(): ResultCapabilities = _client.getChannelCapabilities();
|
||||
override fun getChannelContents(
|
||||
channelUrl: String,
|
||||
type: String?,
|
||||
order: String?,
|
||||
filters: Map<String, List<String>>?
|
||||
): IPager<IPlatformContent> = _client.getChannelContents(channelUrl);
|
||||
|
||||
override fun getChannelUrlByClaim(claimType: Int, claimValues: Map<Int, String>): String? = _client.getChannelUrlByClaim(claimType, claimValues)
|
||||
|
||||
override fun searchSuggestions(query: String): Array<String> = _client.searchSuggestions(query);
|
||||
override fun getSearchCapabilities(): ResultCapabilities = _client.getSearchCapabilities();
|
||||
override fun search(
|
||||
query: String,
|
||||
type: String?,
|
||||
order: String?,
|
||||
filters: Map<String, List<String>>?
|
||||
): IPager<IPlatformContent> = _client.search(query, type, order, filters);
|
||||
|
||||
override fun getSearchChannelContentsCapabilities(): ResultCapabilities = _client.getSearchChannelContentsCapabilities();
|
||||
override fun searchChannelContents(
|
||||
channelUrl: String,
|
||||
query: String,
|
||||
type: String?,
|
||||
order: String?,
|
||||
filters: Map<String, List<String>>?
|
||||
): IPager<IPlatformContent> = _client.searchChannelContents(channelUrl, query, type, order, filters);
|
||||
|
||||
override fun searchChannels(query: String) = _client.searchChannels(query);
|
||||
|
||||
override fun getComments(url: String): IPager<IPlatformComment> = _client.getComments(url);
|
||||
override fun getSubComments(comment: IPlatformComment): IPager<IPlatformComment> = _client.getSubComments(comment);
|
||||
|
||||
override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? = _client.getLiveChatWindow(url);
|
||||
override fun getLiveEvents(url: String): IPager<IPlatformLiveEvent>? = _client.getLiveEvents(url);
|
||||
|
||||
override fun getHome(): IPager<IPlatformContent> = _client.getHome();
|
||||
|
||||
override fun getUserSubscriptions(): Array<String> { return arrayOf(); };
|
||||
|
||||
override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = _client.searchPlaylists(query, type, order, filters);
|
||||
override fun isPlaylistUrl(url: String): Boolean = _client.isPlaylistUrl(url);
|
||||
override fun getPlaylist(url: String): IPlatformPlaylistDetails = _client.getPlaylist(url);
|
||||
override fun getUserPlaylists(): Array<String> { return arrayOf(); };
|
||||
|
||||
override fun isClaimTypeSupported(claimType: Int): Boolean {
|
||||
return _client.isClaimTypeSupported(claimType);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
@@ -84,6 +85,20 @@ interface IPlatformClient {
|
||||
*/
|
||||
fun getChannelContents(channelUrl: String, type: String? = null, order: String? = null, filters: Map<String, List<String>>? = null): IPager<IPlatformContent>;
|
||||
|
||||
/**
|
||||
* Describes what the plugin is capable on peek channel results
|
||||
*/
|
||||
fun getPeekChannelTypes(): List<String>;
|
||||
/**
|
||||
* Peeks contents of a channel, upload time descending
|
||||
*/
|
||||
fun peekChannelContents(channelUrl: String, type: String? = null): List<IPlatformContent>
|
||||
|
||||
/**
|
||||
* Gets all playlists of a channel
|
||||
*/
|
||||
fun getChannelPlaylists(channelUrl: String): IPager<IPlatformPlaylist>
|
||||
|
||||
/**
|
||||
* Gets the channel url associated with a claimType
|
||||
*/
|
||||
@@ -106,6 +121,11 @@ interface IPlatformClient {
|
||||
*/
|
||||
fun getPlaybackTracker(url: String): IPlaybackTracker?;
|
||||
|
||||
/**
|
||||
* Get content recommendations
|
||||
*/
|
||||
fun getContentRecommendations(url: String): IPager<IPlatformContent>?;
|
||||
|
||||
|
||||
//Comments
|
||||
/**
|
||||
|
||||
@@ -13,10 +13,14 @@ data class PlatformClientCapabilities(
|
||||
val hasGetChannelUrlByClaim: Boolean = false,
|
||||
val hasGetChannelTemplateByClaimMap: Boolean = false,
|
||||
val hasGetSearchCapabilities: Boolean = false,
|
||||
val hasGetSearchChannelContentsCapabilities: Boolean = false,
|
||||
val hasGetChannelCapabilities: Boolean = false,
|
||||
val hasGetLiveEvents: Boolean = false,
|
||||
val hasGetLiveChatWindow: Boolean = false,
|
||||
val hasGetContentChapters: Boolean = false
|
||||
val hasGetContentChapters: Boolean = false,
|
||||
val hasPeekChannelContents: Boolean = false,
|
||||
val hasGetChannelPlaylists: Boolean = false,
|
||||
val hasGetContentRecommendations: Boolean = false
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -14,7 +14,7 @@ open class PlatformAuthorLink {
|
||||
val id: PlatformID;
|
||||
val name: String;
|
||||
val url: String;
|
||||
val thumbnail: String?;
|
||||
var thumbnail: String?;
|
||||
var subscribers: Long? = null; //Optional
|
||||
|
||||
constructor(id: PlatformID, name: String, url: String, thumbnail: String? = null, subscribers: Long? = null)
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.futo.platformplayer.api.media.models
|
||||
import com.caoccao.javet.values.reference.V8ValueArray
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8PluginConfig
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
@@ -31,7 +33,7 @@ class Thumbnails {
|
||||
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): Thumbnails {
|
||||
return Thumbnails((value.getOrThrow<V8ValueArray>(config, "sources", "Thumbnails"))
|
||||
.toArray()
|
||||
.map { Thumbnail.fromV8(it as V8ValueObject) }
|
||||
.map { Thumbnail.fromV8(config, it as V8ValueObject) }
|
||||
.toTypedArray());
|
||||
}
|
||||
}
|
||||
@@ -40,10 +42,10 @@ class Thumbnails {
|
||||
data class Thumbnail(val url : String?, val quality : Int = 0) {
|
||||
|
||||
companion object {
|
||||
fun fromV8(value: V8ValueObject): Thumbnail {
|
||||
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): Thumbnail {
|
||||
return Thumbnail(
|
||||
value.getString("url"),
|
||||
value.getInteger("quality"));
|
||||
value.getOrDefault<String>(config,"url", "Thumbnail", null),
|
||||
value.getOrDefault(config, "quality", "Thumbnail", 0) ?: 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
+4
@@ -37,6 +37,10 @@ class SerializedChannel(
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
fun isSameUrl(url: String): Boolean {
|
||||
return this.url == url || urlAlternatives.contains(url);
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromChannel(channel: IPlatformChannel): SerializedChannel {
|
||||
return SerializedChannel(
|
||||
|
||||
+5
-2
@@ -19,8 +19,9 @@ class PolycentricPlatformComment : IPlatformComment {
|
||||
|
||||
val eventPointer: Pointer;
|
||||
val reference: Reference;
|
||||
val parentReference: Reference?;
|
||||
|
||||
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, eventPointer: Pointer, replyCount: Int? = null) {
|
||||
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, eventPointer: Pointer, parentReference: Reference?, replyCount: Int? = null) {
|
||||
this.contextUrl = contextUrl;
|
||||
this.author = author;
|
||||
this.message = msg;
|
||||
@@ -29,6 +30,7 @@ class PolycentricPlatformComment : IPlatformComment {
|
||||
this.replyCount = replyCount;
|
||||
this.eventPointer = eventPointer;
|
||||
this.reference = eventPointer.toReference();
|
||||
this.parentReference = parentReference;
|
||||
}
|
||||
|
||||
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> {
|
||||
@@ -36,10 +38,11 @@ class PolycentricPlatformComment : IPlatformComment {
|
||||
}
|
||||
|
||||
fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment {
|
||||
return PolycentricPlatformComment(contextUrl, author, message, rating, date, eventPointer, replyCount);
|
||||
return PolycentricPlatformComment(contextUrl, author, message, rating, date, eventPointer, parentReference, replyCount);
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PolycentricPlatformComment"
|
||||
val MAX_COMMENT_SIZE = 2000
|
||||
}
|
||||
}
|
||||
+2
@@ -10,4 +10,6 @@ interface IPlatformContentDetails : IPlatformContent {
|
||||
|
||||
fun getComments(client: IPlatformClient): IPager<IPlatformComment>?;
|
||||
fun getPlaybackTracker(): IPlaybackTracker?;
|
||||
|
||||
fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>?;
|
||||
}
|
||||
+1
@@ -3,4 +3,5 @@ package com.futo.platformplayer.api.media.models.live
|
||||
interface ILiveChatWindowDescriptor {
|
||||
val url: String;
|
||||
val removeElements: List<String>;
|
||||
val removeElementsInterval: List<String>;
|
||||
}
|
||||
+1
@@ -3,4 +3,5 @@ package com.futo.platformplayer.api.media.models.modifier
|
||||
interface IModifierOptions {
|
||||
val applyAuthClient: String?;
|
||||
val applyCookieClient: String?;
|
||||
val applyOtherHeaders: Boolean;
|
||||
}
|
||||
+1
@@ -7,4 +7,5 @@ interface IPlaybackTracker {
|
||||
|
||||
fun onInit(seconds: Double);
|
||||
fun onProgress(seconds: Double, isPlaying: Boolean);
|
||||
fun onConcluded();
|
||||
}
|
||||
+1
-1
@@ -8,5 +8,5 @@ interface IPlatformPlaylistDetails: IPlatformPlaylist {
|
||||
//TODO: Determine if this should be IPlatformContent (probably not?)
|
||||
val contents: IPager<IPlatformVideo>;
|
||||
|
||||
fun toPlaylist(): Playlist;
|
||||
fun toPlaylist(onProgress: ((progress: Int) -> Unit)? = null): Playlist;
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.futo.platformplayer.api.media.models.streams.sources
|
||||
|
||||
interface IAudioUrlWidevineSource : IAudioUrlSource {
|
||||
val bearerToken: String
|
||||
val licenseUri: String
|
||||
}
|
||||
+2
@@ -7,6 +7,7 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.*
|
||||
@@ -56,6 +57,7 @@ open class SerializedPlatformVideoDetails(
|
||||
|
||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;
|
||||
override fun getPlaybackTracker(): IPlaybackTracker? = null;
|
||||
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? = null;
|
||||
|
||||
companion object {
|
||||
fun fromVideo(video : IPlatformVideoDetails, subtitleSources: List<SubtitleRawSource>) : SerializedPlatformVideoDetails {
|
||||
|
||||
@@ -8,6 +8,8 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.UUID
|
||||
|
||||
class DevJSClient : JSClient {
|
||||
@@ -115,7 +117,7 @@ class DevJSClient : JSClient {
|
||||
|
||||
//Video
|
||||
override fun isContentDetailsUrl(url: String): Boolean {
|
||||
return StateDeveloper.instance.handleDevCall(devID, "isVideoDetailsUrl"){
|
||||
return StateDeveloper.instance.handleDevCall(devID, "isVideoDetailsUrl(${Json.encodeToString(url)})"){
|
||||
super.isContentDetailsUrl(url);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSCallDocs
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs
|
||||
@@ -27,6 +28,7 @@ import com.futo.platformplayer.api.media.platforms.js.internal.JSDocsParameter
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSOptional
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSParameterDocs
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.IJSContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSChannel
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager
|
||||
@@ -38,6 +40,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSLiveChatWindowDes
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistPager
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
@@ -45,6 +48,7 @@ import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.engine.exceptions.PluginEngineException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptValidationException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -56,8 +60,10 @@ import com.futo.platformplayer.states.StatePlugins
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.OffsetDateTime
|
||||
import kotlin.Exception
|
||||
import kotlin.reflect.full.findAnnotations
|
||||
import kotlin.reflect.jvm.kotlinFunction
|
||||
import kotlin.streams.asSequence
|
||||
|
||||
open class JSClient : IPlatformClient {
|
||||
val config: SourcePluginConfig;
|
||||
@@ -73,6 +79,7 @@ open class JSClient : IPlatformClient {
|
||||
private var _searchCapabilities: ResultCapabilities? = null;
|
||||
private var _searchChannelContentsCapabilities: ResultCapabilities? = null;
|
||||
private var _channelCapabilities: ResultCapabilities? = null;
|
||||
private var _peekChannelTypes: List<String>? = null;
|
||||
|
||||
protected val _script: String;
|
||||
|
||||
@@ -91,7 +98,11 @@ open class JSClient : IPlatformClient {
|
||||
|
||||
private val _busyLock = Object();
|
||||
private var _busyCounter = 0;
|
||||
private var _busyAction = "";
|
||||
val isBusy: Boolean get() = _busyCounter > 0;
|
||||
val isBusyAction: String get() {
|
||||
return _busyAction;
|
||||
}
|
||||
|
||||
val settings: HashMap<String, String?> get() = descriptor.settings;
|
||||
|
||||
@@ -150,6 +161,8 @@ open class JSClient : IPlatformClient {
|
||||
if(it is ScriptCaptchaRequiredException)
|
||||
onCaptchaException.emit(this, it);
|
||||
};
|
||||
|
||||
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
|
||||
}
|
||||
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String) {
|
||||
this._context = context;
|
||||
@@ -173,6 +186,8 @@ open class JSClient : IPlatformClient {
|
||||
if(it is ScriptCaptchaRequiredException)
|
||||
onCaptchaException.emit(this, it);
|
||||
};
|
||||
|
||||
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
|
||||
}
|
||||
|
||||
open fun getCopy(): JSClient {
|
||||
@@ -214,9 +229,12 @@ open class JSClient : IPlatformClient {
|
||||
hasGetChannelTemplateByClaimMap = plugin.executeBoolean("!!source.getChannelTemplateByClaimMap") ?: false,
|
||||
hasGetSearchCapabilities = plugin.executeBoolean("!!source.getSearchCapabilities") ?: false,
|
||||
hasGetChannelCapabilities = plugin.executeBoolean("!!source.getChannelCapabilities") ?: false,
|
||||
hasGetSearchChannelContentsCapabilities = plugin.executeBoolean("!!source.getSearchChannelContentsCapabilities") ?: false,
|
||||
hasGetLiveEvents = plugin.executeBoolean("!!source.getLiveEvents") ?: false,
|
||||
hasGetLiveChatWindow = plugin.executeBoolean("!!source.getLiveChatWindow") ?: false,
|
||||
hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false,
|
||||
hasPeekChannelContents = plugin.executeBoolean("!!source.peekChannelContents") ?: false,
|
||||
hasGetChannelPlaylists = plugin.executeBoolean("!!source.getChannelPlaylists") ?: false
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -260,7 +278,7 @@ open class JSClient : IPlatformClient {
|
||||
}
|
||||
|
||||
@JSDocs(2, "source.getHome()", "Gets the HomeFeed of the platform")
|
||||
override fun getHome(): IPager<IPlatformContent> = isBusyWith {
|
||||
override fun getHome(): IPager<IPlatformContent> = isBusyWith("getHome") {
|
||||
ensureEnabled();
|
||||
return@isBusyWith JSContentPager(config, this,
|
||||
plugin.executeTyped("source.getHome()"));
|
||||
@@ -268,7 +286,7 @@ open class JSClient : IPlatformClient {
|
||||
|
||||
@JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query")
|
||||
@JSDocsParameter("query", "Query to complete suggestions for")
|
||||
override fun searchSuggestions(query: String): Array<String> = isBusyWith {
|
||||
override fun searchSuggestions(query: String): Array<String> = isBusyWith("searchSuggestions") {
|
||||
ensureEnabled();
|
||||
return@isBusyWith plugin.executeTyped<V8ValueArray>("source.searchSuggestions(${Json.encodeToString(query)})")
|
||||
.toArray()
|
||||
@@ -298,7 +316,7 @@ open class JSClient : IPlatformClient {
|
||||
@JSDocsParameter("order", "(optional) Order in which contents should be returned")
|
||||
@JSDocsParameter("filters", "(optional) Filters to apply on contents")
|
||||
@JSDocsParameter("channelId", "(optional) Channel id to search in")
|
||||
override fun search(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith {
|
||||
override fun search(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith("search") {
|
||||
ensureEnabled();
|
||||
return@isBusyWith JSContentPager(config, this,
|
||||
plugin.executeTyped("source.search(${Json.encodeToString(query)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})"));
|
||||
@@ -306,6 +324,9 @@ open class JSClient : IPlatformClient {
|
||||
|
||||
@JSDocs(4, "source.getSearchChannelContentsCapabilities()", "Gets capabilities this plugin has for search videos")
|
||||
override fun getSearchChannelContentsCapabilities(): ResultCapabilities {
|
||||
if(!capabilities.hasGetSearchChannelContentsCapabilities)
|
||||
return ResultCapabilities(listOf(ResultCapabilities.TYPE_MIXED));
|
||||
|
||||
ensureEnabled();
|
||||
if (_searchChannelContentsCapabilities != null)
|
||||
return _searchChannelContentsCapabilities!!;
|
||||
@@ -319,7 +340,7 @@ open class JSClient : IPlatformClient {
|
||||
@JSDocsParameter("type", "(optional) Type of contents to get from search ")
|
||||
@JSDocsParameter("order", "(optional) Order in which contents should be returned")
|
||||
@JSDocsParameter("filters", "(optional) Filters to apply on contents")
|
||||
override fun searchChannelContents(channelUrl: String, query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith {
|
||||
override fun searchChannelContents(channelUrl: String, query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith("searchChannelContents") {
|
||||
ensureEnabled();
|
||||
if(!capabilities.hasSearchChannelContents)
|
||||
throw IllegalStateException("This plugin does not support channel search");
|
||||
@@ -331,7 +352,7 @@ open class JSClient : IPlatformClient {
|
||||
@JSOptional
|
||||
@JSDocs(5, "source.searchChannels(query)", "Searches for channels on the platform")
|
||||
@JSDocsParameter("query", "Query that channels should match")
|
||||
override fun searchChannels(query: String): IPager<PlatformAuthorLink> = isBusyWith {
|
||||
override fun searchChannels(query: String): IPager<PlatformAuthorLink> = isBusyWith("searchChannels") {
|
||||
ensureEnabled();
|
||||
return@isBusyWith JSChannelPager(config, this,
|
||||
plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"));
|
||||
@@ -351,7 +372,7 @@ open class JSClient : IPlatformClient {
|
||||
}
|
||||
@JSDocs(7, "source.getChannel(channelUrl)", "Gets a channel by its url")
|
||||
@JSDocsParameter("channelUrl", "A channel url (this platform)")
|
||||
override fun getChannel(channelUrl: String): IPlatformChannel = isBusyWith {
|
||||
override fun getChannel(channelUrl: String): IPlatformChannel = isBusyWith("getChannel") {
|
||||
ensureEnabled();
|
||||
return@isBusyWith JSChannel(config,
|
||||
plugin.executeTyped("source.getChannel(${Json.encodeToString(channelUrl)})"));
|
||||
@@ -378,12 +399,57 @@ open class JSClient : IPlatformClient {
|
||||
@JSDocsParameter("type", "(optional) Type of contents to get from channel")
|
||||
@JSDocsParameter("order", "(optional) Order in which contents should be returned")
|
||||
@JSDocsParameter("filters", "(optional) Filters to apply on contents")
|
||||
override fun getChannelContents(channelUrl: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith {
|
||||
override fun getChannelContents(channelUrl: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith("getChannelContents") {
|
||||
ensureEnabled();
|
||||
return@isBusyWith JSContentPager(config, this,
|
||||
plugin.executeTyped("source.getChannelContents(${Json.encodeToString(channelUrl)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})"));
|
||||
}
|
||||
|
||||
@JSDocs(10, "source.getChannelPlaylists(url)", "Gets playlists of a channel")
|
||||
@JSDocsParameter("channelUrl", "A channel url (this platform)")
|
||||
override fun getChannelPlaylists(channelUrl: String): IPager<IPlatformPlaylist> = isBusyWith("getChannelPlaylists") {
|
||||
ensureEnabled();
|
||||
if(!capabilities.hasGetChannelPlaylists)
|
||||
return@isBusyWith EmptyPager();
|
||||
return@isBusyWith JSPlaylistPager(config, this,
|
||||
plugin.executeTyped("source.getChannelPlaylists(${Json.encodeToString(channelUrl)})"));
|
||||
}
|
||||
|
||||
@JSDocs(10, "source.getPeekChannelTypes()", "Gets types this plugin has for peek channel contents")
|
||||
override fun getPeekChannelTypes(): List<String> {
|
||||
if(!capabilities.hasPeekChannelContents)
|
||||
return listOf();
|
||||
try {
|
||||
if (_peekChannelTypes != null) {
|
||||
return _peekChannelTypes!!;
|
||||
}
|
||||
val arr: V8ValueArray = plugin.executeTyped("source.getPeekChannelTypes()");
|
||||
|
||||
_peekChannelTypes = arr.keys.mapNotNull {
|
||||
val str = arr.get<V8ValueString>(it);
|
||||
return@mapNotNull str.value;
|
||||
};
|
||||
return _peekChannelTypes ?: listOf();
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
announcePluginUnhandledException("getPeekChannelTypes", ex);
|
||||
return listOf();
|
||||
}
|
||||
}
|
||||
|
||||
@JSDocs(10, "source.peekChannelContents(url, type)", "Peek contents of a channel (reverse chronological order)")
|
||||
@JSDocsParameter("channelUrl", "A channel url (this platform)")
|
||||
@JSDocsParameter("type", "(optional) Type of contents to get from channel")
|
||||
override fun peekChannelContents(channelUrl: String, type: String?): List<IPlatformContent> = isBusyWith("peekChannelContents") {
|
||||
ensureEnabled();
|
||||
|
||||
val items: V8ValueArray = plugin.executeTyped("source.peekChannelContents(${Json.encodeToString(channelUrl)}, ${Json.encodeToString(type)})");
|
||||
return@isBusyWith items.keys.mapNotNull {
|
||||
val obj = items.get<V8ValueObject>(it);
|
||||
return@mapNotNull IJSContent.fromV8(this, obj);
|
||||
};
|
||||
}
|
||||
|
||||
@JSOptional
|
||||
@JSDocs(11, "source.getChannelUrlByClaim(claimType, claimValues)", "Gets the channel url that should be used to fetch a given polycentric claim")
|
||||
@JSDocsParameter("claimType", "Polycentric claimtype id")
|
||||
@@ -444,7 +510,7 @@ open class JSClient : IPlatformClient {
|
||||
}
|
||||
@JSDocs(14, "source.getContentDetails(url)", "Gets content details by its url")
|
||||
@JSDocsParameter("url", "A content url (this platform)")
|
||||
override fun getContentDetails(url: String): IPlatformContentDetails = isBusyWith {
|
||||
override fun getContentDetails(url: String): IPlatformContentDetails = isBusyWith("getContentDetails") {
|
||||
ensureEnabled();
|
||||
return@isBusyWith IJSContentDetails.fromV8(this,
|
||||
plugin.executeTyped("source.getContentDetails(${Json.encodeToString(url)})"));
|
||||
@@ -453,7 +519,7 @@ open class JSClient : IPlatformClient {
|
||||
@JSOptional //getContentChapters = function(url, initialData)
|
||||
@JSDocs(15, "source.getContentChapters(url)", "Gets chapters for content details")
|
||||
@JSDocsParameter("url", "A content url (this platform)")
|
||||
override fun getContentChapters(url: String): List<IChapter> = isBusyWith {
|
||||
override fun getContentChapters(url: String): List<IChapter> = isBusyWith("getContentChapters") {
|
||||
if(!capabilities.hasGetContentChapters)
|
||||
return@isBusyWith listOf();
|
||||
ensureEnabled();
|
||||
@@ -464,7 +530,7 @@ open class JSClient : IPlatformClient {
|
||||
@JSOptional
|
||||
@JSDocs(15, "source.getPlaybackTracker(url)", "Gets a playback tracker for given content url")
|
||||
@JSDocsParameter("url", "A content url (this platform)")
|
||||
override fun getPlaybackTracker(url: String): IPlaybackTracker? = isBusyWith {
|
||||
override fun getPlaybackTracker(url: String): IPlaybackTracker? = isBusyWith("getPlaybackTracker") {
|
||||
if(!capabilities.hasGetPlaybackTracker)
|
||||
return@isBusyWith null;
|
||||
ensureEnabled();
|
||||
@@ -478,7 +544,7 @@ open class JSClient : IPlatformClient {
|
||||
|
||||
@JSDocs(16, "source.getComments(url)", "Gets comments for a content by its url")
|
||||
@JSDocsParameter("url", "A content url (this platform)")
|
||||
override fun getComments(url: String): IPager<IPlatformComment> = isBusyWith {
|
||||
override fun getComments(url: String): IPager<IPlatformComment> = isBusyWith("getComments") {
|
||||
ensureEnabled();
|
||||
val pager = plugin.executeTyped<V8Value>("source.getComments(${Json.encodeToString(url)})");
|
||||
if (pager !is V8ValueObject) { //TODO: Maybe solve this better
|
||||
@@ -494,31 +560,45 @@ open class JSClient : IPlatformClient {
|
||||
plugin.executeTyped("source.getSubComments(${Json.encodeToString(comment as JSComment)})"));
|
||||
}
|
||||
|
||||
@JSDocs(16, "source.getLiveChatWindow(url)", "Gets live events for a livestream")
|
||||
@JSDocs(18, "source.getLiveChatWindow(url)", "Gets live events for a livestream")
|
||||
@JSDocsParameter("url", "Url of live stream")
|
||||
override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? = isBusyWith {
|
||||
override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? = isBusyWith("getLiveChatWindow") {
|
||||
if(!capabilities.hasGetLiveChatWindow)
|
||||
return@isBusyWith null;
|
||||
ensureEnabled();
|
||||
return@isBusyWith JSLiveChatWindowDescriptor(config,
|
||||
plugin.executeTyped("source.getLiveChatWindow(${Json.encodeToString(url)})"));
|
||||
}
|
||||
@JSDocs(16, "source.getLiveEvents(url)", "Gets live events for a livestream")
|
||||
@JSDocs(19, "source.getLiveEvents(url)", "Gets live events for a livestream")
|
||||
@JSDocsParameter("url", "Url of live stream")
|
||||
override fun getLiveEvents(url: String): IPager<IPlatformLiveEvent>? = isBusyWith {
|
||||
override fun getLiveEvents(url: String): IPager<IPlatformLiveEvent>? = isBusyWith("getLiveEvents") {
|
||||
if(!capabilities.hasGetLiveEvents)
|
||||
return@isBusyWith null;
|
||||
ensureEnabled();
|
||||
return@isBusyWith JSLiveEventPager(config, this,
|
||||
plugin.executeTyped("source.getLiveEvents(${Json.encodeToString(url)})"));
|
||||
}
|
||||
|
||||
|
||||
@JSDocs(19, "source.getContentRecommendations(url)", "Gets recommendations of a content page")
|
||||
@JSDocsParameter("url", "Url of content")
|
||||
override fun getContentRecommendations(url: String): IPager<IPlatformContent>? = isBusyWith("getContentRecommendations") {
|
||||
if(!capabilities.hasGetContentRecommendations)
|
||||
return@isBusyWith null;
|
||||
ensureEnabled();
|
||||
return@isBusyWith JSContentPager(config, this,
|
||||
plugin.executeTyped("source.getContentRecommendations(${Json.encodeToString(url)})"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@JSDocs(19, "source.searchPlaylists(query)", "Searches for playlists on the platform")
|
||||
@JSDocsParameter("query", "Query that search results should match")
|
||||
@JSDocsParameter("type", "(optional) Type of contents to get from search ")
|
||||
@JSDocsParameter("order", "(optional) Order in which contents should be returned")
|
||||
@JSDocsParameter("filters", "(optional) Filters to apply on contents")
|
||||
@JSDocsParameter("channelId", "(optional) Channel id to search in")
|
||||
override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith {
|
||||
override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith("searchPlaylists") {
|
||||
ensureEnabled();
|
||||
if(!capabilities.hasSearchPlaylists)
|
||||
throw IllegalStateException("This plugin does not support playlist search");
|
||||
@@ -528,15 +608,22 @@ open class JSClient : IPlatformClient {
|
||||
@JSDocs(20, "source.isPlaylistUrl(url)", "Validates if a playlist url is for this platform")
|
||||
@JSDocsParameter("url", "Url of playlist")
|
||||
override fun isPlaylistUrl(url: String): Boolean {
|
||||
ensureEnabled();
|
||||
if (!capabilities.hasGetPlaylist)
|
||||
return false;
|
||||
return plugin.executeBoolean("source.isPlaylistUrl(${Json.encodeToString(url)})") ?: false;
|
||||
|
||||
try {
|
||||
return plugin.executeTyped<V8ValueBoolean>("source.isPlaylistUrl(${Json.encodeToString(url)})")
|
||||
.value;
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
announcePluginUnhandledException("isPlaylistUrl", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@JSOptional
|
||||
@JSDocs(21, "source.getPlaylist(url)", "Gets the playlist of the current user")
|
||||
@JSDocsParameter("url", "Url of playlist")
|
||||
override fun getPlaylist(url: String): IPlatformPlaylistDetails = isBusyWith {
|
||||
override fun getPlaylist(url: String): IPlatformPlaylistDetails = isBusyWith("getPlaylist") {
|
||||
ensureEnabled();
|
||||
return@isBusyWith JSPlaylistDetails(this, plugin.config as SourcePluginConfig, plugin.executeTyped("source.getPlaylist(${Json.encodeToString(url)})"));
|
||||
}
|
||||
@@ -633,19 +720,24 @@ open class JSClient : IPlatformClient {
|
||||
}
|
||||
|
||||
|
||||
private fun <T> isBusyWith(handle: ()->T): T {
|
||||
private fun <T> isBusyWith(actionName: String, handle: ()->T): T {
|
||||
try {
|
||||
synchronized(_busyLock) {
|
||||
_busyCounter++;
|
||||
}
|
||||
_busyAction = actionName;
|
||||
return handle();
|
||||
}
|
||||
finally {
|
||||
_busyAction = "";
|
||||
synchronized(_busyLock) {
|
||||
_busyCounter--;
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun <T> isBusyWith(handle: ()->T): T {
|
||||
return isBusyWith("Unknown", handle);
|
||||
}
|
||||
|
||||
private fun announcePluginUnhandledException(method: String, ex: Throwable) {
|
||||
if(ex is PluginEngineException)
|
||||
@@ -662,10 +754,43 @@ open class JSClient : IPlatformClient {
|
||||
|
||||
companion object {
|
||||
val TAG = "JSClient";
|
||||
private val _lock = Object();
|
||||
private var _docs: Map<String, String>? = null;
|
||||
|
||||
fun getMethodDocs(names: List<String>): Map<String, String>? {
|
||||
synchronized(_lock) {
|
||||
if(_docs == null) {
|
||||
val client = ManagedHttpClient();
|
||||
val docs = names
|
||||
.map { stringWithoutBrackets(it) }
|
||||
.distinct()
|
||||
.parallelStream()
|
||||
.map {
|
||||
val url = "https://github.com/futo-org/grayjay-android/blob/master/docs/source/${it}.md";
|
||||
val resp = client.head(url);
|
||||
if(resp.isOk)
|
||||
return@map Pair(it, url);
|
||||
else
|
||||
return@map null;
|
||||
}.asSequence()
|
||||
.filterNotNull()
|
||||
.toMap();
|
||||
_docs = docs;
|
||||
}
|
||||
return _docs;
|
||||
}
|
||||
}
|
||||
fun getMethodDocUrls(): Map<String, String>? {
|
||||
if(_docs != null)
|
||||
return _docs;
|
||||
val methods = JSClient::class.java.declaredMethods.filter { it.getAnnotation(JSDocs::class.java) != null }
|
||||
return getMethodDocs(methods.map { it.name });
|
||||
}
|
||||
|
||||
fun getJSDocs(): List<JSCallDocs> {
|
||||
val docs = mutableListOf<JSCallDocs>();
|
||||
val methods = JSClient::class.java.declaredMethods.filter { it.getAnnotation(JSDocs::class.java) != null }
|
||||
|
||||
for(method in methods.sortedBy { it.getAnnotation(JSDocs::class.java)?.order }) {
|
||||
val doc = method.getAnnotation(JSDocs::class.java);
|
||||
val parameters = method.kotlinFunction!!.findAnnotations<JSDocsParameter>();
|
||||
@@ -678,5 +803,12 @@ open class JSClient : IPlatformClient {
|
||||
}
|
||||
return docs;
|
||||
}
|
||||
|
||||
private fun stringWithoutBrackets(name: String): String {
|
||||
val index = name.indexOf('(');
|
||||
if(index >= 0)
|
||||
return name.substring(0, index);
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -11,4 +11,5 @@ class SourcePluginAuthConfig(
|
||||
val userAgent: String? = null,
|
||||
val loginButton: String? = null,
|
||||
val domainHeadersToFind: Map<String, List<String>>? = null,
|
||||
val loginWarning: String? = null
|
||||
) { }
|
||||
+46
-1
@@ -45,7 +45,9 @@ class SourcePluginConfig(
|
||||
var enableInSearch: Boolean = true,
|
||||
var enableInHome: Boolean = true,
|
||||
var supportedClaimTypes: List<Int> = listOf(),
|
||||
var primaryClaimFieldType: Int? = null
|
||||
var primaryClaimFieldType: Int? = null,
|
||||
var developerSubmitUrl: String? = null,
|
||||
var allowAllHttpHeaderAccess: Boolean = false,
|
||||
) : IV8PluginConfig {
|
||||
|
||||
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
|
||||
@@ -79,6 +81,44 @@ class SourcePluginConfig(
|
||||
return _allowUrlsLowerVal!!;
|
||||
};
|
||||
|
||||
fun isLowRiskUpdate(oldScript: String, newConfig: SourcePluginConfig, newScript: String): Boolean{
|
||||
//New allow header access
|
||||
if(!allowAllHttpHeaderAccess && newConfig.allowAllHttpHeaderAccess)
|
||||
return false;
|
||||
|
||||
//All urls should already be allowed
|
||||
for(url in newConfig.allowUrls) {
|
||||
if(!allowUrls.contains(url))
|
||||
return false;
|
||||
}
|
||||
//All packages should already be allowed
|
||||
for(pack in newConfig.packages) {
|
||||
if(!packages.contains(pack))
|
||||
return false;
|
||||
}
|
||||
//Developer Submit Url should be same or empty
|
||||
if(!newConfig.developerSubmitUrl.isNullOrEmpty() && developerSubmitUrl != newConfig.developerSubmitUrl)
|
||||
return false;
|
||||
|
||||
//Should have a public key
|
||||
if(scriptPublicKey.isNullOrEmpty() || scriptSignature.isNullOrEmpty())
|
||||
return false;
|
||||
|
||||
//Should be same public key
|
||||
if(scriptPublicKey != newConfig.scriptPublicKey)
|
||||
return false;
|
||||
|
||||
//Old signature should be valid
|
||||
if(!validate(oldScript))
|
||||
return false;
|
||||
|
||||
//New signature should be valid
|
||||
if(!newConfig.validate(newScript))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
fun getWarnings(scriptToCheck: String? = null) : List<Pair<String,String>> {
|
||||
val list = mutableListOf<Pair<String,String>>();
|
||||
|
||||
@@ -107,6 +147,11 @@ class SourcePluginConfig(
|
||||
list.add(Pair(
|
||||
"Unrestricted Web Access",
|
||||
"This plugin requires access to all URLs, this may include malicious URLs."));
|
||||
if(allowAllHttpHeaderAccess)
|
||||
list.add(Pair(
|
||||
"Unrestricted Http Header access",
|
||||
"Allows this plugin to access all headers (including cookies and authorization headers) for unauthenticated requests."
|
||||
))
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
+34
-2
@@ -2,9 +2,13 @@ package com.futo.platformplayer.api.media.platforms.js
|
||||
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.views.fields.DropdownFieldOptions
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
import com.futo.platformplayer.views.fields.FormFieldWarning
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@@ -55,7 +59,16 @@ class SourcePluginDescriptor {
|
||||
onCaptchaChanged.emit();
|
||||
}
|
||||
fun getCaptchaData(): SourceCaptchaData? {
|
||||
return SourceCaptchaData.fromEncrypted(captchaEncrypted);
|
||||
try {
|
||||
return SourceCaptchaData.fromEncrypted(captchaEncrypted);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e("SourcePluginDescriptor", "Captcha decode failed, disabling auth.", ex);
|
||||
StateAnnouncement.instance.registerAnnouncement("CAP_BROKEN_" + config.id,
|
||||
"Captcha corrupted for plugin [${config.name}]",
|
||||
"Something went wrong in the stored captcha, you'll have to login again", AnnouncementType.SESSION);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAuth(str: SourceAuth?) {
|
||||
@@ -63,12 +76,26 @@ class SourcePluginDescriptor {
|
||||
onAuthChanged.emit();
|
||||
}
|
||||
fun getAuth(): SourceAuth? {
|
||||
return SourceAuth.fromEncrypted(authEncrypted);
|
||||
try {
|
||||
return SourceAuth.fromEncrypted(authEncrypted);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e("SourcePluginDescriptor", "Authentication decode failed, disabling auth.", ex);
|
||||
StateAnnouncement.instance.registerAnnouncement("AUTH_BROKEN_" + config.id,
|
||||
"Authentication corrupted for plugin [${config.name}]",
|
||||
"Something went wrong in the stored authentication, you'll have to login again", AnnouncementType.SESSION);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class AppPluginSettings {
|
||||
|
||||
@FormField(R.string.check_for_updates_setting, FieldForm.TOGGLE, R.string.check_for_updates_setting_description, -1)
|
||||
var checkForUpdates: Boolean = true;
|
||||
@FormField(R.string.automatic_update_setting, FieldForm.TOGGLE, R.string.automatic_update_setting_description, 0)
|
||||
var automaticUpdate: Boolean = false;
|
||||
|
||||
@FormField(R.string.visibility, "group", R.string.enable_where_this_plugins_content_are_visible, 2)
|
||||
var tabEnabled = TabEnabled();
|
||||
@Serializable
|
||||
@@ -106,6 +133,11 @@ class SourcePluginDescriptor {
|
||||
}
|
||||
|
||||
|
||||
|
||||
@FormField(R.string.allow_developer_submit, FieldForm.TOGGLE, R.string.allow_developer_submit_description, 1, "devSubmit")
|
||||
var allowDeveloperSubmit: Boolean = false;
|
||||
|
||||
|
||||
fun loadDefaults(config: SourcePluginConfig) {
|
||||
if(tabEnabled.enableHome == null)
|
||||
tabEnabled.enableHome = config.enableInHome
|
||||
|
||||
@@ -14,6 +14,6 @@ annotation class JSOptional()
|
||||
annotation class JSDocsParameter(val name: String, val description: String, val order: Int = 0)
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
data class JSCallDocs(val title: String, val code: String, val description: String, val parameters: List<JSParameterDocs>, val isOptional: Boolean = false);
|
||||
data class JSCallDocs(val title: String, val code: String, val description: String, val parameters: List<JSParameterDocs>, val isOptional: Boolean = false, val docsUrl: String? = null);
|
||||
@kotlinx.serialization.Serializable
|
||||
data class JSParameterDocs(val name: String, val description: String);
|
||||
+27
-1
@@ -2,14 +2,22 @@ package com.futo.platformplayer.api.media.platforms.js.internal
|
||||
|
||||
import android.net.Uri
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSRequest
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestModifier
|
||||
import com.futo.platformplayer.developer.DeveloperEndpoints
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.matchesDomain
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import com.google.common.net.MediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okio.GzipSource
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.util.UUID
|
||||
|
||||
class JSHttpClient : ManagedHttpClient {
|
||||
@@ -28,7 +36,15 @@ class JSHttpClient : ManagedHttpClient {
|
||||
private var _currentCookieMap: HashMap<String, HashMap<String, String>>;
|
||||
private var _otherCookieMap: HashMap<String, HashMap<String, String>>;
|
||||
|
||||
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, config: SourcePluginConfig? = null) : super() {
|
||||
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, config: SourcePluginConfig? = null) : super(
|
||||
//Temporary ugly solution for DevPortal proxy support
|
||||
(if(jsClient?.config?.id == StateDeveloper.DEV_ID && StateDeveloper.instance.devProxy != null)
|
||||
OkHttpClient.Builder().proxy(Proxy(Proxy.Type.HTTP,
|
||||
InetSocketAddress(StateDeveloper.instance.devProxy!!.url, StateDeveloper.instance.devProxy!!.port)
|
||||
))
|
||||
else
|
||||
OkHttpClient.Builder())
|
||||
) {
|
||||
_jsClient = jsClient;
|
||||
_jsConfig = config;
|
||||
_auth = auth;
|
||||
@@ -201,6 +217,16 @@ class JSHttpClient : ManagedHttpClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(_jsClient is DevJSClient) {
|
||||
//val peekBody = resp.peekBody(1000 * 1000).string();
|
||||
StateDeveloper.instance.addDevHttpExchange(
|
||||
StateDeveloper.DevHttpExchange(
|
||||
StateDeveloper.DevHttpRequest(resp.request.method, resp.request.url.toString(), mapOf(*resp.request.headers.map { Pair(it.first, it.second) }.toTypedArray()), ""),
|
||||
StateDeveloper.DevHttpRequest("RESP", resp.request.url.toString(), mapOf(*resp.headers.map { Pair(it.first, it.second) }.toTypedArray()), "", resp.code)
|
||||
));
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@ import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrDefaultList
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullable
|
||||
@@ -37,7 +38,7 @@ class JSChannel : IPlatformChannel {
|
||||
description = _channel.getOrThrowNullable(config, "description", contextName);
|
||||
url = _channel.getOrThrow(config, "url", contextName);
|
||||
urlAlternatives = _channel.getOrDefaultList(config, "urlAlternatives", contextName, listOf()) ?: listOf();
|
||||
links = HashMap();
|
||||
links = HashMap(_channel.getOrDefault<Map<String, String>>(config, "links", contextName, mapOf()) ?: mapOf());
|
||||
}
|
||||
|
||||
override fun getContents(client: IPlatformClient): IPager<IPlatformContent> {
|
||||
|
||||
+2
-1
@@ -14,12 +14,13 @@ import java.time.ZoneOffset
|
||||
class JSLiveChatWindowDescriptor: ILiveChatWindowDescriptor {
|
||||
override val url: String;
|
||||
override val removeElements: List<String>;
|
||||
|
||||
override val removeElementsInterval: List<String>;
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject) {
|
||||
val contextName = "LiveChatWindowDescriptor";
|
||||
|
||||
url = obj.getOrThrow(config, "url", contextName);
|
||||
removeElements = obj.getOrDefault(config, "removeElements", contextName, listOf()) ?: listOf();
|
||||
removeElementsInterval = obj.getOrDefault(config, "removeElementsInterval", contextName, listOf()) ?: listOf();
|
||||
}
|
||||
}
|
||||
+13
@@ -17,6 +17,8 @@ class JSPlaybackTracker: IPlaybackTracker {
|
||||
|
||||
private var _lastRequest: Long = Long.MIN_VALUE;
|
||||
|
||||
private val _hasOnConcluded: Boolean;
|
||||
|
||||
override var nextRequest: Int = 1000
|
||||
private set;
|
||||
|
||||
@@ -26,6 +28,7 @@ class JSPlaybackTracker: IPlaybackTracker {
|
||||
throw ScriptImplementationException(config, "Missing onProgress on PlaybackTracker");
|
||||
if(!obj.has("nextRequest"))
|
||||
throw ScriptImplementationException(config, "Missing nextRequest on PlaybackTracker");
|
||||
_hasOnConcluded = obj.has("onConcluded");
|
||||
|
||||
this._config = config;
|
||||
this._obj = obj;
|
||||
@@ -59,6 +62,16 @@ class JSPlaybackTracker: IPlaybackTracker {
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onConcluded() {
|
||||
warnIfMainThread("JSPlaybackTracker.onConcluded");
|
||||
if(_hasOnConcluded) {
|
||||
synchronized(_obj) {
|
||||
Logger.i("JSPlaybackTracker", "onConcluded");
|
||||
_obj.invokeVoid("onConcluded", -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun shouldUpdate(): Boolean = (_lastRequest < 0 || (System.currentTimeMillis() - _lastRequest) > nextRequest);
|
||||
}
|
||||
+1
-1
@@ -14,6 +14,6 @@ open class JSPlaylist : JSContent, IPlatformPlaylist {
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
|
||||
val contextName = "Playlist";
|
||||
thumbnail = obj.getOrDefault(config, "thumbnail", contextName, null);
|
||||
videoCount = obj.getOrDefault(config, "videoCount", contextName, 0)!!;
|
||||
videoCount = obj.getOrDefault(config, "videoCount", contextName, -1)!!;
|
||||
}
|
||||
}
|
||||
+11
-7
@@ -7,7 +7,7 @@ import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.api.media.structures.ReusablePager
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
|
||||
@@ -15,22 +15,26 @@ class JSPlaylistDetails: JSPlaylist, IPlatformPlaylistDetails {
|
||||
override val contents: IPager<IPlatformVideo>;
|
||||
|
||||
constructor(plugin: JSClient, config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
|
||||
contents = JSVideoPager(config, plugin, obj.getOrThrow(config, "contents", "PlaylistDetails"));
|
||||
contents = ReusablePager(JSVideoPager(config, plugin, obj.getOrThrow(config, "contents", "PlaylistDetails")));
|
||||
}
|
||||
|
||||
override fun toPlaylist(): Playlist {
|
||||
val videos = contents.getResults().toMutableList();
|
||||
override fun toPlaylist(onProgress: ((progress: Int) -> Unit)?): Playlist {
|
||||
val playlist = if (contents is ReusablePager) contents.getWindow() else contents;
|
||||
val videos = playlist.getResults().toMutableList();
|
||||
onProgress?.invoke(videos.size);
|
||||
|
||||
//Download all pages
|
||||
var allowedEmptyCount = 2;
|
||||
while(contents.hasMorePages()) {
|
||||
contents.nextPage();
|
||||
if(!videos.addAll(contents.getResults())) {
|
||||
while(playlist.hasMorePages()) {
|
||||
playlist.nextPage();
|
||||
if(!videos.addAll(playlist.getResults())) {
|
||||
allowedEmptyCount--;
|
||||
if(allowedEmptyCount <= 0)
|
||||
break;
|
||||
}
|
||||
else allowedEmptyCount = 2;
|
||||
|
||||
onProgress?.invoke(videos.size);
|
||||
}
|
||||
|
||||
return Playlist(id.toString(), name, videos.map { SerializedPlatformVideo.fromVideo(it)});
|
||||
|
||||
+21
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.platforms.js.models
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails
|
||||
@@ -18,6 +19,7 @@ import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
|
||||
private val _hasGetComments: Boolean;
|
||||
private val _hasGetContentRecommendations: Boolean;
|
||||
|
||||
override val rating: IRating;
|
||||
|
||||
@@ -34,6 +36,7 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
|
||||
content = obj.getOrDefault(config, "content", contextName, "") ?: "";
|
||||
|
||||
_hasGetComments = _content.has("getComments");
|
||||
_hasGetContentRecommendations = _content.has("getContentRecommendations");
|
||||
}
|
||||
|
||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
|
||||
@@ -51,9 +54,27 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
|
||||
}
|
||||
override fun getPlaybackTracker(): IPlaybackTracker? = null;
|
||||
|
||||
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
|
||||
if(!_hasGetContentRecommendations || _content.isClosed)
|
||||
return null;
|
||||
|
||||
if(client is DevJSClient)
|
||||
return StateDeveloper.instance.handleDevCall(client.devID, "postDetail.getContentRecommendations()") {
|
||||
return@handleDevCall getContentRecommendationsJS(client);
|
||||
}
|
||||
else if(client is JSClient)
|
||||
return getContentRecommendationsJS(client);
|
||||
|
||||
return null;
|
||||
}
|
||||
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
|
||||
val contentPager = _content.invoke<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
return JSContentPager(_pluginConfig, client, contentPager);
|
||||
}
|
||||
|
||||
private fun getCommentsJS(client: JSClient): JSCommentPager {
|
||||
val commentPager = _content.invoke<V8ValueObject>("getComments", arrayOf<Any>());
|
||||
return JSCommentPager(_pluginConfig, client, commentPager);
|
||||
}
|
||||
|
||||
}
|
||||
+23
-5
@@ -23,21 +23,31 @@ class JSRequest : IRequest {
|
||||
_v8Options = options;
|
||||
initialize(plugin, originalUrl, originalHeaders);
|
||||
}
|
||||
constructor(plugin: JSClient, obj: V8ValueObject, originalUrl: String?, originalHeaders: Map<String, String>?) {
|
||||
constructor(plugin: JSClient, obj: V8ValueObject, originalUrl: String?, originalHeaders: Map<String, String>?, applyOtherHeadersByDefault: Boolean = false) {
|
||||
val contextName = "ModifyRequestResponse";
|
||||
val config = plugin.config;
|
||||
_v8Url = obj.getOrDefault<String>(config, "url", contextName, null);
|
||||
_v8Headers = obj.getOrDefault<Map<String, String>>(config, "headers", contextName, null);
|
||||
_v8Options = obj.getOrDefault<V8ValueObject>(config, "options", "JSRequestModifier.options", null)?.let {
|
||||
Options(config, it);
|
||||
}
|
||||
Options(config, it, applyOtherHeadersByDefault);
|
||||
} ?: Options(null, null, applyOtherHeadersByDefault);
|
||||
initialize(plugin, originalUrl, originalHeaders);
|
||||
}
|
||||
|
||||
private fun initialize(plugin: JSClient, originalUrl: String?, originalHeaders: Map<String, String>?) {
|
||||
val config = plugin.config;
|
||||
url = _v8Url ?: originalUrl;
|
||||
headers = _v8Headers ?: originalHeaders ?: mapOf();
|
||||
|
||||
if(_v8Options?.applyOtherHeaders ?: false) {
|
||||
val headersToSet = _v8Headers?.toMutableMap() ?: mutableMapOf();
|
||||
if (originalHeaders != null)
|
||||
for (head in originalHeaders)
|
||||
if (!headersToSet.containsKey(head.key))
|
||||
headersToSet[head.key] = head.value;
|
||||
headers = headersToSet;
|
||||
}
|
||||
else
|
||||
headers = _v8Headers ?: originalHeaders ?: mapOf();
|
||||
|
||||
if(_v8Options != null) {
|
||||
if(_v8Options.applyCookieClient != null && url != null) {
|
||||
@@ -68,10 +78,18 @@ class JSRequest : IRequest {
|
||||
class Options: IModifierOptions {
|
||||
override val applyAuthClient: String?;
|
||||
override val applyCookieClient: String?;
|
||||
override val applyOtherHeaders: Boolean;
|
||||
|
||||
constructor(config: IV8PluginConfig, obj: V8ValueObject) {
|
||||
|
||||
constructor(config: IV8PluginConfig, obj: V8ValueObject, applyOtherHeadersByDefault: Boolean = false) {
|
||||
applyAuthClient = obj.getOrDefault(config, "applyAuthClient", "JSRequestModifier.options.applyAuthClient", null);
|
||||
applyCookieClient = obj.getOrDefault(config, "applyCookieClient", "JSRequestModifier.options.applyCookieClient", null);
|
||||
applyOtherHeaders = obj.getOrDefault(config, "applyOtherHeaders", "JSRequestModifier.options.applyOtherHeaders", applyOtherHeadersByDefault) ?: applyOtherHeadersByDefault;
|
||||
}
|
||||
constructor(applyAuthClient: String? = null, applyCookieClient: String? = null, applyOtherHeaders: Boolean = false) {
|
||||
this.applyAuthClient = applyAuthClient;
|
||||
this.applyCookieClient = applyCookieClient;
|
||||
this.applyOtherHeaders = applyOtherHeaders;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -40,6 +40,7 @@ class JSRequestModifier: IRequestModifier {
|
||||
} as V8ValueObject;
|
||||
|
||||
val req = JSRequest(_plugin, result, url, headers);
|
||||
result.close();
|
||||
return req;
|
||||
}
|
||||
|
||||
|
||||
+21
@@ -6,6 +6,7 @@ import com.caoccao.javet.values.reference.V8ValueArray
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
@@ -27,6 +28,7 @@ import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
private val _hasGetComments: Boolean;
|
||||
private val _hasGetContentRecommendations: Boolean;
|
||||
private val _hasGetPlaybackTracker: Boolean;
|
||||
|
||||
//Details
|
||||
@@ -66,6 +68,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
|
||||
_hasGetComments = _content.has("getComments");
|
||||
_hasGetPlaybackTracker = _content.has("getPlaybackTracker");
|
||||
_hasGetContentRecommendations = _content.has("getContentRecommendations");
|
||||
}
|
||||
|
||||
override fun getPlaybackTracker(): IPlaybackTracker? {
|
||||
@@ -89,6 +92,24 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
};
|
||||
}
|
||||
|
||||
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
|
||||
if(!_hasGetContentRecommendations || _content.isClosed)
|
||||
return null;
|
||||
|
||||
if(client is DevJSClient)
|
||||
return StateDeveloper.instance.handleDevCall(client.devID, "videoDetail.getContentRecommendations()") {
|
||||
return@handleDevCall getContentRecommendationsJS(client);
|
||||
}
|
||||
else if(client is JSClient)
|
||||
return getContentRecommendationsJS(client);
|
||||
|
||||
return null;
|
||||
}
|
||||
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
|
||||
val contentPager = _content.invoke<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
return JSContentPager(_pluginConfig, client, contentPager);
|
||||
}
|
||||
|
||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
|
||||
if(client !is JSClient || !_hasGetComments || _content.isClosed)
|
||||
return null;
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js.models.sources
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlWidevineSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class JSAudioUrlWidevineSource : JSAudioUrlSource, IAudioUrlWidevineSource {
|
||||
override val bearerToken: String
|
||||
override val licenseUri: String
|
||||
|
||||
@Suppress("ConvertSecondaryConstructorToPrimary")
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin, obj) {
|
||||
val contextName = "JSAudioUrlWidevineSource"
|
||||
val config = plugin.config
|
||||
bearerToken = _obj.getOrThrow(config, "bearerToken", contextName)
|
||||
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
val url = getAudioUrl()
|
||||
return "(name=$name, container=$container, bitrate=$bitrate, codec=$codec, url=$url, language=$language, duration=$duration, bearerToken=$bearerToken, licenseUri=$licenseUri)"
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -33,7 +33,7 @@ abstract class JSSource {
|
||||
this.type = type;
|
||||
|
||||
_requestModifier = obj.getOrDefault<V8ValueObject>(_config, "requestModifier", "JSSource.requestModifier", null)?.let {
|
||||
JSRequest(plugin, it, null, null);
|
||||
JSRequest(plugin, it, null, null, true);
|
||||
}
|
||||
hasRequestModifier = _requestModifier != null || obj.has("getRequestModifier");
|
||||
}
|
||||
@@ -66,6 +66,7 @@ abstract class JSSource {
|
||||
const val TYPE_VIDEO_WITH_METADATA = "VideoUrlRangeSource";
|
||||
const val TYPE_DASH = "DashSource";
|
||||
const val TYPE_HLS = "HLSSource";
|
||||
const val TYPE_AUDIOURL_WIDEVINE = "AudioUrlWidevineSource"
|
||||
|
||||
fun fromV8VideoNullable(plugin: JSClient, obj: V8Value?) : IVideoSource? = obj.orNull { fromV8Video(plugin, it as V8ValueObject) };
|
||||
fun fromV8Video(plugin: JSClient, obj: V8ValueObject) : IVideoSource {
|
||||
@@ -88,6 +89,7 @@ abstract class JSSource {
|
||||
return when(type) {
|
||||
TYPE_HLS -> JSHLSManifestAudioSource.fromV8HLS(plugin, obj);
|
||||
TYPE_AUDIOURL -> JSAudioUrlSource(plugin, obj);
|
||||
TYPE_AUDIOURL_WIDEVINE -> JSAudioUrlWidevineSource(plugin, obj);
|
||||
TYPE_AUDIO_WITH_METADATA -> JSAudioUrlRangeSource(plugin, obj);
|
||||
else -> throw NotImplementedError("Unknown type ${type}");
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ class AirPlayCastingDevice : CastingDevice {
|
||||
try {
|
||||
val connectedSocket = getConnectedSocket(adrs.toList(), port);
|
||||
if (connectedSocket == null) {
|
||||
delay(3000);
|
||||
delay(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -163,24 +163,25 @@ class AirPlayCastingDevice : CastingDevice {
|
||||
}
|
||||
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
delay(1000);
|
||||
|
||||
val progressIndex = progressInfo.lowercase().indexOf("position: ");
|
||||
if (progressIndex == -1) {
|
||||
delay(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue;
|
||||
setTime(progress);
|
||||
|
||||
|
||||
val durationIndex = progressInfo.lowercase().indexOf("duration: ");
|
||||
if (durationIndex == -1) {
|
||||
delay(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
val duration = progressInfo.substring(durationIndex + "duration: ".length).toDoubleOrNull() ?: continue;
|
||||
setDuration(duration);
|
||||
delay(1000);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get server info from AirPlay device.", e)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import org.json.JSONObject
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocket
|
||||
@@ -42,7 +44,9 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
|
||||
private var _socket: SSLSocket? = null;
|
||||
private var _outputStream: DataOutputStream? = null;
|
||||
private var _outputStreamLock = Object();
|
||||
private var _inputStream: DataInputStream? = null;
|
||||
private var _inputStreamLock = Object();
|
||||
private var _scopeIO: CoroutineScope? = null;
|
||||
private var _requestId = 1;
|
||||
private var _started: Boolean = false;
|
||||
@@ -303,17 +307,18 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
_thread = Thread {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
var connectedSocket: Socket? = null
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val connectedSocket = getConnectedSocket(adrs.toList(), port);
|
||||
if (connectedSocket == null) {
|
||||
Thread.sleep(3000);
|
||||
val resultSocket = getConnectedSocket(adrs.toList(), port);
|
||||
if (resultSocket == null) {
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
connectedSocket = resultSocket
|
||||
usedRemoteAddress = connectedSocket.inetAddress;
|
||||
localAddress = connectedSocket.localAddress;
|
||||
connectedSocket.close();
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
|
||||
@@ -325,6 +330,8 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
|
||||
val factory = sslContext.socketFactory;
|
||||
|
||||
val address = InetSocketAddress(usedRemoteAddress, port)
|
||||
|
||||
//Connection loop
|
||||
while (_scopeIO?.isActive == true) {
|
||||
Logger.i(TAG, "Connecting to Chromecast.");
|
||||
@@ -332,7 +339,16 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
_socket = factory.createSocket(usedRemoteAddress, port) as SSLSocket;
|
||||
if (connectedSocket != null) {
|
||||
Logger.i(TAG, "Using connected socket.")
|
||||
_socket = factory.createSocket(connectedSocket, connectedSocket.inetAddress.hostAddress, connectedSocket.port, true) as SSLSocket
|
||||
connectedSocket = null
|
||||
} else {
|
||||
Logger.i(TAG, "Using new socket.")
|
||||
val s = Socket().apply { this.connect(address, 2000) }
|
||||
_socket = factory.createSocket(s, s.inetAddress.hostAddress, s.port, true) as SSLSocket
|
||||
}
|
||||
|
||||
_socket?.startHandshake();
|
||||
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
|
||||
|
||||
@@ -347,7 +363,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
Logger.i(TAG, "Failed to connect to Chromecast.", e);
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -363,45 +379,50 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
_socket?.close();
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
getStatus();
|
||||
|
||||
val buffer = ByteArray(4096);
|
||||
val buffer = ByteArray(409600);
|
||||
|
||||
Logger.i(TAG, "Started receiving.");
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val inputStream = _inputStream ?: break;
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
val b1 = inputStream.readUnsignedByte();
|
||||
val b2 = inputStream.readUnsignedByte();
|
||||
val b3 = inputStream.readUnsignedByte();
|
||||
val b4 = inputStream.readUnsignedByte();
|
||||
val size = ((b1.toLong() shl 24) or (b2.toLong() shl 16) or (b3.toLong() shl 8) or b4.toLong()).toInt();
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
||||
inputStream.skip(size.toLong());
|
||||
continue;
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||
inputStream.read(buffer, 0, size);
|
||||
synchronized(_inputStreamLock)
|
||||
{
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
val b1 = inputStream.readUnsignedByte();
|
||||
val b2 = inputStream.readUnsignedByte();
|
||||
val b3 = inputStream.readUnsignedByte();
|
||||
val b4 = inputStream.readUnsignedByte();
|
||||
val size =
|
||||
((b1.toLong() shl 24) or (b2.toLong() shl 16) or (b3.toLong() shl 8) or b4.toLong()).toInt();
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
||||
inputStream.skip(size.toLong());
|
||||
return@synchronized
|
||||
}
|
||||
|
||||
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
val message = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
Logger.i(TAG, "Received message: $message");
|
||||
}
|
||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||
inputStream.read(buffer, 0, size);
|
||||
|
||||
try {
|
||||
handleMessage(message);
|
||||
} catch (e:Throwable) {
|
||||
Logger.w(TAG, "Failed to handle message.", e);
|
||||
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
val message = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
Logger.i(TAG, "Received message: $message");
|
||||
}
|
||||
|
||||
try {
|
||||
handleMessage(message);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to handle message.", e);
|
||||
}
|
||||
}
|
||||
} catch (e: java.net.SocketException) {
|
||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
||||
@@ -415,7 +436,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped connection loop.");
|
||||
@@ -432,10 +453,11 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString());
|
||||
Thread.sleep(5000);
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to send ping.");
|
||||
}
|
||||
|
||||
Thread.sleep(5000);
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped ping loop.");
|
||||
@@ -573,13 +595,16 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
val serializedSizeBE = ByteArray(4);
|
||||
serializedSizeBE[0] = (data.size shr 24 and 0xff).toByte();
|
||||
serializedSizeBE[1] = (data.size shr 16 and 0xff).toByte();
|
||||
serializedSizeBE[2] = (data.size shr 8 and 0xff).toByte();
|
||||
serializedSizeBE[3] = (data.size and 0xff).toByte();
|
||||
outputStream.write(serializedSizeBE);
|
||||
outputStream.write(data);
|
||||
synchronized(_outputStreamLock)
|
||||
{
|
||||
val serializedSizeBE = ByteArray(4);
|
||||
serializedSizeBE[0] = (data.size shr 24 and 0xff).toByte();
|
||||
serializedSizeBE[1] = (data.size shr 16 and 0xff).toByte();
|
||||
serializedSizeBE[2] = (data.size shr 8 and 0xff).toByte();
|
||||
serializedSizeBE[3] = (data.size and 0xff).toByte();
|
||||
outputStream.write(serializedSizeBE);
|
||||
outputStream.write(data);
|
||||
}
|
||||
|
||||
//Log.d(TAG, "Sent ${data.size} bytes.");
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.futo.platformplayer.casting.models.FCastSetSpeedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastSetVolumeMessage
|
||||
import com.futo.platformplayer.casting.models.FCastVersionMessage
|
||||
import com.futo.platformplayer.casting.models.FCastVolumeUpdateMessage
|
||||
import com.futo.platformplayer.ensureNotMainThread
|
||||
import com.futo.platformplayer.getConnectedSocket
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
@@ -27,11 +28,12 @@ import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.math.BigInteger
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyPair
|
||||
@@ -81,12 +83,15 @@ class FCastCastingDevice : CastingDevice {
|
||||
var port: Int = 0;
|
||||
|
||||
private var _socket: Socket? = null;
|
||||
private var _outputStream: DataOutputStream? = null;
|
||||
private var _inputStream: DataInputStream? = null;
|
||||
private var _outputStream: OutputStream? = null;
|
||||
private var _inputStream: InputStream? = null;
|
||||
private var _scopeIO: CoroutineScope? = null;
|
||||
private var _started: Boolean = false;
|
||||
private var _version: Long = 1;
|
||||
private var _thread: Thread? = null
|
||||
private var _pingThread: Thread? = null
|
||||
private var _lastPongTime = -1L
|
||||
private var _outputStreamLock = Object()
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
this.name = name;
|
||||
@@ -206,7 +211,13 @@ class FCastCastingDevice : CastingDevice {
|
||||
|
||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
||||
if(Looper.getMainLooper().thread == Thread.currentThread()) {
|
||||
_scopeIO?.launch { action(); }
|
||||
_scopeIO?.launch {
|
||||
try {
|
||||
action();
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to invoke in IO scope.", e)
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -240,77 +251,113 @@ class FCastCastingDevice : CastingDevice {
|
||||
val adrs = addresses ?: return;
|
||||
|
||||
val thread = _thread
|
||||
if (thread == null || !thread.isAlive) {
|
||||
Log.i(TAG, "Restarting thread because the thread has died")
|
||||
val pingThread = _pingThread
|
||||
if (_started && (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive)) {
|
||||
Log.i(TAG, "(Re)starting thread because the thread has died")
|
||||
|
||||
_scopeIO?.let {
|
||||
it.cancel()
|
||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
||||
}
|
||||
|
||||
_scopeIO?.cancel();
|
||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
|
||||
_thread = Thread {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Log.i(TAG, "Connection thread started.")
|
||||
|
||||
var connectedSocket: Socket? = null
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val connectedSocket = getConnectedSocket(adrs.toList(), port);
|
||||
if (connectedSocket == null) {
|
||||
Thread.sleep(3000);
|
||||
Log.i(TAG, "getConnectedSocket (adrs = [ ${adrs.joinToString(", ")} ], port = ${port}).")
|
||||
|
||||
val resultSocket = getConnectedSocket(adrs.toList(), port);
|
||||
|
||||
if (resultSocket == null) {
|
||||
Log.i(TAG, "Connection failed, waiting 1 seconds.")
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
usedRemoteAddress = connectedSocket.inetAddress;
|
||||
localAddress = connectedSocket.localAddress;
|
||||
connectedSocket.close();
|
||||
Log.i(TAG, "Connection succeeded.")
|
||||
|
||||
connectedSocket = resultSocket
|
||||
usedRemoteAddress = connectedSocket.inetAddress
|
||||
localAddress = connectedSocket.localAddress
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(ChromecastCastingDevice.TAG, "Failed to get setup initial connection to FastCast device.", e)
|
||||
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
|
||||
}
|
||||
}
|
||||
|
||||
val address = InetSocketAddress(usedRemoteAddress, port)
|
||||
|
||||
//Connection loop
|
||||
while (_scopeIO?.isActive == true) {
|
||||
Logger.i(TAG, "Connecting to FastCast.");
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
try {
|
||||
_socket = Socket(usedRemoteAddress, port);
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
if (connectedSocket != null) {
|
||||
Logger.i(TAG, "Using connected socket.");
|
||||
_socket = connectedSocket
|
||||
connectedSocket = null
|
||||
} else {
|
||||
Logger.i(TAG, "Using new socket.");
|
||||
_socket = Socket().apply { this.connect(address, 2000) };
|
||||
}
|
||||
Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port");
|
||||
|
||||
_outputStream = DataOutputStream(_socket?.outputStream);
|
||||
_inputStream = DataInputStream(_socket?.inputStream);
|
||||
_outputStream = _socket?.outputStream;
|
||||
_inputStream = _socket?.inputStream;
|
||||
} catch (e: IOException) {
|
||||
_socket?.close();
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
Logger.i(TAG, "Failed to connect to FastCast.", e);
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
localAddress = _socket?.localAddress;
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
_lastPongTime = -1L
|
||||
|
||||
val buffer = ByteArray(4096);
|
||||
|
||||
Logger.i(TAG, "Started receiving.");
|
||||
var exceptionOccurred = false;
|
||||
while (_scopeIO?.isActive == true && !exceptionOccurred) {
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val inputStream = _inputStream ?: break;
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
val b1 = inputStream.readUnsignedByte();
|
||||
val b2 = inputStream.readUnsignedByte();
|
||||
val b3 = inputStream.readUnsignedByte();
|
||||
val b4 = inputStream.readUnsignedByte();
|
||||
val size = ((b4.toLong() shl 24) or (b3.toLong() shl 16) or (b2.toLong() shl 8) or b1.toLong()).toInt();
|
||||
|
||||
var headerBytesRead = 0
|
||||
while (headerBytesRead < 4) {
|
||||
val read = inputStream.read(buffer, headerBytesRead, 4 - headerBytesRead)
|
||||
if (read == -1)
|
||||
throw Exception("Stream closed")
|
||||
headerBytesRead += read
|
||||
}
|
||||
|
||||
val size = ((buffer[3].toLong() shl 24) or (buffer[2].toLong() shl 16) or (buffer[1].toLong() shl 8) or buffer[0].toLong()).toInt();
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
||||
inputStream.skip(size.toLong());
|
||||
continue;
|
||||
Logger.w(TAG, "Packets larger than $size bytes are not supported.")
|
||||
break
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||
inputStream.read(buffer, 0, size);
|
||||
var bytesRead = 0
|
||||
while (bytesRead < size) {
|
||||
val read = inputStream.read(buffer, bytesRead, size - bytesRead)
|
||||
if (read == -1)
|
||||
throw Exception("Stream closed")
|
||||
bytesRead += read
|
||||
}
|
||||
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
@@ -324,31 +371,68 @@ class FCastCastingDevice : CastingDevice {
|
||||
try {
|
||||
handleMessage(Opcode.find(opcode), json);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to handle message.", e);
|
||||
Logger.w(TAG, "Failed to handle message.", e)
|
||||
break
|
||||
}
|
||||
} catch (e: java.net.SocketException) {
|
||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
||||
exceptionOccurred = true;
|
||||
break
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Exception while receiving.", e);
|
||||
exceptionOccurred = true;
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
_socket?.close();
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(3000);
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped connection loop.");
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
}.apply { start() };
|
||||
}.apply { start() }
|
||||
|
||||
_pingThread = Thread {
|
||||
Logger.i(TAG, "Started ping loop.")
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
send(Opcode.Ping)
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to send ping.")
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
}
|
||||
|
||||
/*if (_lastPongTime != -1L && System.currentTimeMillis() - _lastPongTime > 6000) {
|
||||
Logger.w(TAG, "Closing socket due to last pong time being larger than 6 seconds.")
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
}*/
|
||||
|
||||
Thread.sleep(2000)
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped ping loop.");
|
||||
}.apply { start() }
|
||||
} else {
|
||||
Log.i(TAG, "Thread was still alive, not restarted")
|
||||
}
|
||||
@@ -401,39 +485,44 @@ class FCastCastingDevice : CastingDevice {
|
||||
Logger.i(TAG, "Remote version received: $version")
|
||||
}
|
||||
Opcode.Ping -> send(Opcode.Pong)
|
||||
Opcode.Pong -> _lastPongTime = System.currentTimeMillis()
|
||||
else -> { }
|
||||
}
|
||||
}
|
||||
|
||||
private fun send(opcode: Opcode, message: String? = null) {
|
||||
try {
|
||||
val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0)
|
||||
val size = 1 + data.size
|
||||
val outputStream = _outputStream
|
||||
if (outputStream == null) {
|
||||
Log.w(TAG, "Failed to send $size bytes, output stream is null.")
|
||||
return
|
||||
ensureNotMainThread()
|
||||
|
||||
synchronized (_outputStreamLock) {
|
||||
try {
|
||||
val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0)
|
||||
val size = 1 + data.size
|
||||
val outputStream = _outputStream
|
||||
if (outputStream == null) {
|
||||
Log.w(TAG, "Failed to send $size bytes, output stream is null.")
|
||||
return
|
||||
}
|
||||
|
||||
val serializedSizeLE = ByteArray(4)
|
||||
serializedSizeLE[0] = (size and 0xff).toByte()
|
||||
serializedSizeLE[1] = (size shr 8 and 0xff).toByte()
|
||||
serializedSizeLE[2] = (size shr 16 and 0xff).toByte()
|
||||
serializedSizeLE[3] = (size shr 24 and 0xff).toByte()
|
||||
outputStream.write(serializedSizeLE)
|
||||
|
||||
val opcodeBytes = ByteArray(1)
|
||||
opcodeBytes[0] = opcode.value
|
||||
outputStream.write(opcodeBytes)
|
||||
|
||||
if (data.isNotEmpty()) {
|
||||
outputStream.write(data)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Sent $size bytes: (opcode: $opcode, body: $message).")
|
||||
} catch (e: Throwable) {
|
||||
Log.i(TAG, "Failed to send message.", e)
|
||||
throw e
|
||||
}
|
||||
|
||||
val serializedSizeLE = ByteArray(4)
|
||||
serializedSizeLE[0] = (size and 0xff).toByte()
|
||||
serializedSizeLE[1] = (size shr 8 and 0xff).toByte()
|
||||
serializedSizeLE[2] = (size shr 16 and 0xff).toByte()
|
||||
serializedSizeLE[3] = (size shr 24 and 0xff).toByte()
|
||||
outputStream.write(serializedSizeLE)
|
||||
|
||||
val opcodeBytes = ByteArray(1)
|
||||
opcodeBytes[0] = opcode.value
|
||||
outputStream.write(opcodeBytes)
|
||||
|
||||
if (data.isNotEmpty()) {
|
||||
outputStream.write(data)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Sent $size bytes: (opcode: $opcode, body: $message).")
|
||||
} catch (e: Throwable) {
|
||||
Log.i(TAG, "Failed to send message.", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,6 +542,7 @@ class FCastCastingDevice : CastingDevice {
|
||||
_started = false;
|
||||
//TODO: Kill and/or join thread?
|
||||
_thread = null;
|
||||
_pingThread = null;
|
||||
|
||||
val socket = _socket;
|
||||
val scopeIO = _scopeIO;
|
||||
@@ -462,6 +552,8 @@ class FCastCastingDevice : CastingDevice {
|
||||
|
||||
scopeIO.launch {
|
||||
socket.close();
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
scopeIO.cancel();
|
||||
Logger.i(TAG, "Cancelled scopeIO with open socket.")
|
||||
|
||||
@@ -242,6 +242,7 @@ class StateCasting {
|
||||
jmDNS.addServiceListener("_googlecast._tcp.local.", _chromecastServiceListener);
|
||||
jmDNS.addServiceListener("_airplay._tcp.local.", _airPlayServiceListener);
|
||||
jmDNS.addServiceListener("_fastcast._tcp.local.", _fastCastServiceListener);
|
||||
jmDNS.addServiceListener("_fcast._tcp.local.", _fastCastServiceListener);
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
jmDNS.addServiceTypeListener(_serviceTypeListener);
|
||||
|
||||
@@ -9,7 +9,10 @@ import com.futo.platformplayer.api.http.server.HttpGET
|
||||
import com.futo.platformplayer.api.http.server.HttpPOST
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.engine.dev.V8RemoteObject
|
||||
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.gsonStandard
|
||||
@@ -20,18 +23,29 @@ import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateAssets
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.google.gson.ExclusionStrategy
|
||||
import com.google.gson.FieldAttributes
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.lang.reflect.Field
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.lang.reflect.Modifier
|
||||
import java.util.UUID
|
||||
import kotlin.reflect.full.findAnnotation
|
||||
import kotlin.reflect.full.memberFunctions
|
||||
import kotlin.reflect.jvm.javaType
|
||||
import kotlin.reflect.jvm.jvmErasure
|
||||
|
||||
class DeveloperEndpoints(private val context: Context) {
|
||||
private val TAG = "DeveloperEndpoints";
|
||||
private val _client = ManagedHttpClient();
|
||||
private var _testPlugin: V8Plugin? = null;
|
||||
private var _testPluginFull: JSClient? = null;
|
||||
private val testPluginOrThrow: V8Plugin get() = _testPlugin ?: throw IllegalStateException("Attempted to use test plugin without plugin");
|
||||
private val _testPluginVariables: HashMap<String, V8RemoteObject> = hashMapOf();
|
||||
|
||||
@@ -90,15 +104,22 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
@HttpGET("/source_docs.js", "application/javascript")
|
||||
val devSourceDocsJS = "const sourceDocs = $devSourceDocsJson";
|
||||
|
||||
@HttpGET("/source_doc_urls.json", "application/json")
|
||||
fun devSourceDocUrlsJson(httpContext: HttpContext) {;
|
||||
val docs = JSClient.getMethodDocUrls();
|
||||
httpContext.respondCode(200, Json.encodeToString(docs), "application/json");
|
||||
}
|
||||
@HttpGET("/source_doc_urls.js", "application/javascript")
|
||||
fun devSourceDocUrlsJs(httpContext: HttpContext) {;
|
||||
val docs = JSClient.getMethodDocUrls();
|
||||
httpContext.respondCode(200, "const sourceDocUrls = " + Json.encodeToString(docs), "application/javascript");
|
||||
}
|
||||
|
||||
//Dependencies
|
||||
//@HttpGET("/dependencies/vue.js", "application/javascript")
|
||||
//val depVue = StateAssets.readAsset(context, "devportal/dependencies/vue.js", true);
|
||||
//@HttpGET("/dependencies/vuetify.js", "application/javascript")
|
||||
//val depVuetify = StateAssets.readAsset(context, "devportal/dependencies/vuetify.js", true);
|
||||
//@HttpGET("/dependencies/vuetify.min.css", "text/css")
|
||||
//val depVuetifyCss = StateAssets.readAsset(context, "devportal/dependencies/vuetify.min.css", true);
|
||||
@HttpGET("/dependencies/FutoMainLogo.svg", "image/svg+xml")
|
||||
val depFutoLogo = StateAssets.readAsset(context, "devportal/dependencies/FutoMainLogo.svg");
|
||||
@HttpGET("/favicon.svg", "image/svg+xml")
|
||||
val favicon = StateAssets.readAsset(context, "devportal/dependencies/favicon.svg");
|
||||
|
||||
@HttpGET("/reference_plugin.d.ts", "text/plain")
|
||||
fun devSourceTSWithRefs(httpContext: HttpContext) {
|
||||
@@ -190,6 +211,17 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
val client = JSHttpClient(null, null, null, config);
|
||||
val clientAuth = JSHttpClient(null, null, null, config);
|
||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, client, clientAuth);
|
||||
try {
|
||||
val script = _client.get(config.absoluteScriptUrl);
|
||||
_testPluginFull = JSClient(StateApp.instance.context, SourcePluginDescriptor(
|
||||
config, null, null, null
|
||||
), null, script.body?.string() ?: "");
|
||||
_testPluginFull!!.initialize();
|
||||
}
|
||||
catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Loading full client failed", ex);
|
||||
_testPluginFull = null;
|
||||
}
|
||||
|
||||
context.respondJson(200, testPluginOrThrow.getPackageVariables());
|
||||
}
|
||||
@@ -412,6 +444,25 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
context.respondCode(500, ex::class.simpleName + ":" + ex.message, "text/plain")
|
||||
}
|
||||
}
|
||||
@HttpGET("/dev/setDevProxy")
|
||||
fun devSetDevProxy(context: HttpContext) {
|
||||
try {
|
||||
val url = context.query.getOrDefault("url", "");
|
||||
val port = context.query.getOrDefault("port", "");
|
||||
if(url.isNullOrEmpty() || port.isNullOrEmpty() || port.toIntOrNull() == null)
|
||||
{
|
||||
StateDeveloper.instance.devProxy = null;
|
||||
context.respondCode(400);
|
||||
return;
|
||||
}
|
||||
StateDeveloper.instance.devProxy = StateDeveloper.DevProxySettings(url, port.toInt());
|
||||
context.respondCode(200, "true", "application/json");
|
||||
}
|
||||
catch(ex: Exception) {
|
||||
Logger.e("DeveloperEndpoints", ex.message, ex);
|
||||
context.respondCode(500, ex::class.simpleName + ":" + ex.message, "text/plain")
|
||||
}
|
||||
}
|
||||
|
||||
@HttpGET("/plugin/getDevLogs")
|
||||
fun pluginGetDevLogs(context: HttpContext) {
|
||||
@@ -423,6 +474,15 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
context.respondCode(500, ex.message ?: "", "text/plain")
|
||||
}
|
||||
}
|
||||
@HttpGET("/plugin/getDevHttpExchanges")
|
||||
fun pluginGetDevExchanges(context: HttpContext) {
|
||||
try {
|
||||
context.respondJson(200, StateDeveloper.instance.getHttpExchangesAndClear());
|
||||
}
|
||||
catch(ex: Exception) {
|
||||
context.respondCode(500, ex.message ?: "", "text/plain")
|
||||
}
|
||||
}
|
||||
@HttpGET("/plugin/fakeDevLog")
|
||||
fun pluginFakeDevLog(context: HttpContext) {
|
||||
try {
|
||||
@@ -440,6 +500,68 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private val _fieldAttributesField = FieldAttributes::class.java.getDeclaredField("field");
|
||||
init {
|
||||
_fieldAttributesField.isAccessible = true;
|
||||
}
|
||||
private val _remoteTestGson = GsonBuilder()
|
||||
.setExclusionStrategies(object : ExclusionStrategy {
|
||||
override fun shouldSkipClass(clazz: Class<*>?): Boolean {
|
||||
return clazz?.simpleName == "JSClient" ||
|
||||
clazz?.simpleName == "KSerializer[]" ||
|
||||
clazz?.simpleName == "V8ValueObject";
|
||||
}
|
||||
|
||||
override fun shouldSkipField(f: FieldAttributes?): Boolean {
|
||||
val isPublic = f?.hasModifier(Modifier.PUBLIC) ?: true;
|
||||
if(!isPublic) {
|
||||
val underlyingField = _fieldAttributesField.get(f) as Field;
|
||||
return !(underlyingField.declaringClass as Class).methods.any { it.name == "get" + underlyingField.name.replaceFirstChar { it.uppercaseChar() } && Modifier.isPublic(it.modifiers) };
|
||||
}
|
||||
else
|
||||
return !isPublic;
|
||||
}
|
||||
}).create();
|
||||
@HttpPOST("/plugin/remoteTest")
|
||||
fun pluginRemoteTest(context: HttpContext) {
|
||||
val method = context.query.getOrDefault("method", "");
|
||||
try {
|
||||
|
||||
val parameters = context.readContentString();
|
||||
val paras = JsonParser.parseString(parameters);
|
||||
if(!paras.isJsonArray)
|
||||
throw IllegalArgumentException("Expected json array as body");
|
||||
|
||||
val plugin = _testPluginFull ?: throw IllegalStateException("Plugin not loaded");
|
||||
|
||||
val function = plugin::class.memberFunctions.filter { it.findAnnotation<JSDocs>() != null }
|
||||
.find { it.name == method };
|
||||
if(function == null)
|
||||
throw java.lang.IllegalArgumentException("Plugin method [${function}] not found");
|
||||
val callResult = function.call(*(listOf(plugin) + paras.asJsonArray.take(function.parameters.size - 1).mapIndexed { index, jsonElement ->
|
||||
//For now, manual conversion.
|
||||
val parameter = function.parameters[index + 1];
|
||||
val value = _remoteTestGson.fromJson<Any>(jsonElement, parameter.type.javaType);
|
||||
return@mapIndexed value;
|
||||
}).toTypedArray());
|
||||
val json = if(callResult is IPager<*>)
|
||||
_remoteTestGson.toJson(callResult.getResults())
|
||||
else
|
||||
_remoteTestGson.toJson(callResult);
|
||||
//val json = wrapRemoteResult(callResult, false);
|
||||
|
||||
context.respondCode(200, json);
|
||||
}
|
||||
catch(ex: InvocationTargetException) {
|
||||
Logger.e(TAG, "Remote test for [${method}] is failed", ex.targetException);
|
||||
context.respondCode(500, ex.targetException.message ?: "", "text/plain")
|
||||
}
|
||||
catch(ex: Exception) {
|
||||
Logger.e(TAG, "Remote test for [${method}] is failed", ex);
|
||||
context.respondCode(500, ex.message ?: "", "text/plain")
|
||||
}
|
||||
}
|
||||
|
||||
//Internal calls
|
||||
@HttpPOST("/get")
|
||||
fun get(context: HttpContext) {
|
||||
|
||||
@@ -96,6 +96,11 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.")
|
||||
}
|
||||
|
||||
fun hideExceptionButtons() {
|
||||
_buttonNever.visibility = View.GONE
|
||||
_buttonShowChangelog.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun update() {
|
||||
_buttonShowChangelog.visibility = Button.GONE;
|
||||
_buttonNever.visibility = Button.GONE;
|
||||
|
||||
@@ -123,7 +123,8 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
|
||||
msg = comment,
|
||||
rating = RatingLikeDislikes(0, 0),
|
||||
date = OffsetDateTime.now(),
|
||||
eventPointer = eventPointer
|
||||
eventPointer = eventPointer,
|
||||
parentReference = ref
|
||||
));
|
||||
|
||||
dismiss();
|
||||
|
||||
@@ -143,7 +143,9 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
StateCasting.instance.onActiveDeviceDurationChanged.remove(this);
|
||||
StateCasting.instance.onActiveDeviceDurationChanged.subscribe {
|
||||
_sliderPosition.valueTo = it.toFloat().coerceAtLeast(1.0f);
|
||||
val dur = it.toFloat().coerceAtLeast(1.0f)
|
||||
_sliderPosition.value = _sliderPosition.value.coerceAtLeast(0.0f).coerceAtMost(dur);
|
||||
_sliderPosition.valueTo = dur
|
||||
};
|
||||
|
||||
_device = StateCasting.instance.activeDevice;
|
||||
@@ -185,8 +187,10 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
_sliderPosition.valueFrom = 0.0f;
|
||||
_sliderVolume.valueFrom = 0.0f;
|
||||
_sliderVolume.value = d.volume.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo);
|
||||
_sliderPosition.valueTo = d.duration.toFloat().coerceAtLeast(1.0f);
|
||||
_sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo);
|
||||
|
||||
val dur = d.duration.toFloat().coerceAtLeast(1.0f)
|
||||
_sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur)
|
||||
_sliderPosition.valueTo = dur
|
||||
|
||||
if (d.canSetVolume) {
|
||||
_layoutVolumeAdjustable.visibility = View.VISIBLE;
|
||||
|
||||
@@ -22,7 +22,9 @@ import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||
import com.futo.platformplayer.assume
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImportCache
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateBackup
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -66,13 +68,15 @@ class ImportDialog : AlertDialog {
|
||||
private val _name: String;
|
||||
private val _toImport: List<String>;
|
||||
|
||||
private val _cache: ImportCache?;
|
||||
|
||||
constructor(context: Context, importStore: ManagedStore<*>, name: String, toReconstruct: List<String>, onConcluded: ()->Unit): super(context) {
|
||||
constructor(context: Context, importStore: ManagedStore<*>, name: String, toReconstruct: List<String>, cache: ImportCache?, onConcluded: ()->Unit): super(context) {
|
||||
_context = context;
|
||||
_store = importStore;
|
||||
_onConcluded = onConcluded;
|
||||
_name = name;
|
||||
_toImport = ArrayList(toReconstruct);
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -146,7 +150,7 @@ class ImportDialog : AlertDialog {
|
||||
val scope = StateApp.instance.scopeOrNull;
|
||||
scope?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val migrationResult = _store.importReconstructions(_toImport) { finished, total ->
|
||||
val migrationResult = _store.importReconstructions(_toImport, _cache) { finished, total ->
|
||||
scope.launch(Dispatchers.Main) {
|
||||
_textProgress.text = "${finished}/${total}";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package com.futo.platformplayer.dialogs
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.media.MediaCas.PluginDescriptor
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableString
|
||||
import android.text.method.ScrollingMovementMethod
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.Button
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.AddSourceActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
||||
import com.futo.platformplayer.assume
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImportCache
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateBackup
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class PluginUpdateDialog : AlertDialog {
|
||||
companion object {
|
||||
private val TAG = "PluginUpdateDialog";
|
||||
}
|
||||
private val _context: Context;
|
||||
|
||||
private lateinit var _buttonCancel1: Button;
|
||||
private lateinit var _buttonCancel2: Button;
|
||||
private lateinit var _buttonUpdate: LinearLayout;
|
||||
|
||||
private lateinit var _buttonOk: LinearLayout;
|
||||
private lateinit var _buttonInstall: LinearLayout;
|
||||
|
||||
private lateinit var _textPlugin: TextView;
|
||||
private lateinit var _textProgres: TextView;
|
||||
private lateinit var _textError: TextView;
|
||||
private lateinit var _textResult: TextView;
|
||||
|
||||
private lateinit var _uiChoiceTop: FrameLayout;
|
||||
private lateinit var _uiProgressTop: FrameLayout;
|
||||
private lateinit var _uiRiskTop: FrameLayout;
|
||||
|
||||
private lateinit var _uiChoiceBot: LinearLayout;
|
||||
private lateinit var _uiResultBot: LinearLayout;
|
||||
private lateinit var _uiRiskBot: LinearLayout;
|
||||
private lateinit var _uiProgressBot: LinearLayout;
|
||||
|
||||
private lateinit var _iconPlugin: ImageView;
|
||||
private lateinit var _updateSpinner: ImageView;
|
||||
|
||||
private var _isUpdating = false;
|
||||
|
||||
private val _oldConfig: SourcePluginConfig;
|
||||
private val _newConfig: SourcePluginConfig;
|
||||
|
||||
|
||||
constructor(context: Context, oldConfig: SourcePluginConfig, newConfig: SourcePluginConfig): super(context) {
|
||||
_context = context;
|
||||
_oldConfig = oldConfig;
|
||||
_newConfig = newConfig;
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_plugin_update, null));
|
||||
|
||||
_buttonCancel1 = findViewById(R.id.button_cancel_1);
|
||||
_buttonCancel2 = findViewById(R.id.button_cancel_2);
|
||||
_buttonUpdate = findViewById(R.id.button_update);
|
||||
|
||||
_buttonOk = findViewById(R.id.button_ok);
|
||||
_buttonInstall = findViewById(R.id.button_install);
|
||||
|
||||
_textPlugin = findViewById(R.id.text_plugin);
|
||||
_textProgres = findViewById(R.id.text_progress);
|
||||
_textError = findViewById(R.id.text_error);
|
||||
_textResult = findViewById(R.id.text_result);
|
||||
|
||||
_uiChoiceTop = findViewById(R.id.dialog_ui_choice_top);
|
||||
_uiProgressTop = findViewById(R.id.dialog_ui_progress_top);
|
||||
_uiRiskTop = findViewById(R.id.dialog_ui_risk_top);
|
||||
|
||||
_uiChoiceBot = findViewById(R.id.dialog_ui_bottom_choice);
|
||||
_uiResultBot = findViewById(R.id.dialog_ui_bottom_result);
|
||||
_uiRiskBot = findViewById(R.id.dialog_ui_bottom_risk);
|
||||
_uiProgressBot = findViewById(R.id.dialog_ui_bottom_progress);
|
||||
|
||||
_updateSpinner = findViewById(R.id.update_spinner);
|
||||
_iconPlugin = findViewById(R.id.icon_plugin);
|
||||
|
||||
_buttonCancel1.setOnClickListener {
|
||||
dismiss();
|
||||
};
|
||||
_buttonCancel2.setOnClickListener {
|
||||
dismiss();
|
||||
};
|
||||
_buttonUpdate.setOnClickListener {
|
||||
if (_isUpdating)
|
||||
return@setOnClickListener;
|
||||
_isUpdating = true;
|
||||
update();
|
||||
};
|
||||
|
||||
Glide.with(_iconPlugin)
|
||||
.load(_oldConfig.absoluteIconUrl)
|
||||
.fallback(R.drawable.ic_sources)
|
||||
.into(_iconPlugin);
|
||||
_textPlugin.text = _oldConfig.name;
|
||||
|
||||
val descriptor = StatePlugins.instance.getPlugin(_oldConfig.id);
|
||||
if(descriptor != null) {
|
||||
if(descriptor.appSettings.automaticUpdate) {
|
||||
if (_isUpdating)
|
||||
return;
|
||||
_isUpdating = true;
|
||||
update();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
super.dismiss();
|
||||
}
|
||||
|
||||
private fun update() {
|
||||
_uiChoiceTop.visibility = View.GONE;
|
||||
_uiRiskTop.visibility = View.GONE;
|
||||
_uiChoiceBot.visibility = View.GONE;
|
||||
_uiResultBot.visibility = View.GONE;
|
||||
_uiRiskBot.visibility = View.GONE;
|
||||
_uiProgressTop.visibility = View.VISIBLE;
|
||||
_uiProgressBot.visibility = View.VISIBLE;
|
||||
|
||||
setCancelable(false);
|
||||
setCanceledOnTouchOutside(false);
|
||||
|
||||
Logger.i(TAG, "Keep screen on set import")
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
|
||||
_updateSpinner.drawable?.assume<Animatable>()?.start();
|
||||
|
||||
val scope = StateApp.instance.scopeOrNull;
|
||||
scope?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val client = ManagedHttpClient();
|
||||
val script = StatePlugins.instance.getScript(_oldConfig.id) ?: "";
|
||||
|
||||
val newScript = client.get(_newConfig.absoluteScriptUrl)?.body?.string();
|
||||
if(newScript.isNullOrEmpty())
|
||||
throw IllegalStateException("No script found");
|
||||
|
||||
if(_oldConfig.isLowRiskUpdate(script, _newConfig, newScript)){
|
||||
|
||||
StatePlugins.instance.installPluginBackground(context, StateApp.instance.scope, _newConfig, newScript,
|
||||
{ text: String, progress: Double ->
|
||||
_textProgres.setText(text);
|
||||
},
|
||||
{ ex ->
|
||||
if(ex == null) {
|
||||
StatePlugins.instance.clearUpdateAvailable(_newConfig);
|
||||
_iconPlugin.setImageResource(R.drawable.ic_check);
|
||||
_textError.visibility = View.GONE;
|
||||
_textResult.visibility = View.VISIBLE;
|
||||
}
|
||||
else {
|
||||
_iconPlugin.setImageResource(R.drawable.ic_error_pred);
|
||||
_textError.text = ex.message + "\n\nYou can retry inside the sources tab";
|
||||
_textError.visibility = View.VISIBLE;
|
||||
_textResult.visibility = View.GONE;
|
||||
}
|
||||
try {
|
||||
_buttonOk.setOnClickListener {
|
||||
dismiss();
|
||||
}
|
||||
_uiProgressTop.visibility = View.GONE;
|
||||
_uiProgressBot.visibility = View.GONE;
|
||||
_uiChoiceTop.visibility = View.VISIBLE;
|
||||
_uiResultBot.visibility = View.VISIBLE;
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to update UI.", e)
|
||||
} finally {
|
||||
Logger.i(TAG, "Keep screen on unset update")
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
else {
|
||||
withContext(Dispatchers.Main) {
|
||||
try {
|
||||
_buttonInstall.setOnClickListener {
|
||||
dismiss();
|
||||
|
||||
val intent = Intent(_context, AddSourceActivity::class.java).apply {
|
||||
data = Uri.parse(_newConfig.sourceUrl)
|
||||
};
|
||||
|
||||
_context.startActivity(intent);
|
||||
}
|
||||
|
||||
_uiProgressTop.visibility = View.GONE;
|
||||
_uiProgressBot.visibility = View.GONE;
|
||||
_uiRiskTop.visibility = View.VISIBLE;
|
||||
_uiRiskBot.visibility = View.VISIBLE;
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to update UI.", e)
|
||||
} finally {
|
||||
Logger.i(TAG, "Keep screen on unset update")
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to update.", e);
|
||||
withContext(Dispatchers.Main) {
|
||||
_buttonOk.setOnClickListener {
|
||||
dismiss();
|
||||
}
|
||||
_iconPlugin.setImageResource(R.drawable.ic_error_pred);
|
||||
_textResult.visibility = View.GONE;
|
||||
_uiProgressTop.visibility = View.GONE;
|
||||
_uiProgressBot.visibility = View.GONE;
|
||||
_uiChoiceTop.visibility = View.VISIBLE;
|
||||
_uiResultBot.visibility = View.VISIBLE;
|
||||
_textError.visibility = View.VISIBLE;
|
||||
_textError.text = e.message + "\n\nYou can retry inside the sources tab"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,8 +337,10 @@ class VideoDownload {
|
||||
});
|
||||
}
|
||||
|
||||
var wasSuccesful = false;
|
||||
try {
|
||||
awaitAll(*sourcesToDownload.toTypedArray());
|
||||
wasSuccesful = true;
|
||||
}
|
||||
catch(runtimeEx: RuntimeException) {
|
||||
if(runtimeEx.cause != null)
|
||||
@@ -349,6 +351,29 @@ class VideoDownload {
|
||||
catch(ex: Throwable) {
|
||||
throw ex;
|
||||
}
|
||||
finally {
|
||||
if(!wasSuccesful) {
|
||||
try {
|
||||
if(videoFilePath != null) {
|
||||
val remainingVideo = File(videoFilePath!!);
|
||||
if (remainingVideo.exists()) {
|
||||
Logger.i(TAG, "Deleting remaining video file");
|
||||
remainingVideo.delete();
|
||||
}
|
||||
}
|
||||
if(audioFilePath != null) {
|
||||
val remainingAudio = File(audioFilePath!!);
|
||||
if (remainingAudio.exists()) {
|
||||
Logger.i(TAG, "Deleting remaining audio file");
|
||||
remainingAudio.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(iex: Throwable) {
|
||||
Logger.e(TAG, "Failed to delete files after failure:\n${iex.message}", iex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
@@ -730,6 +755,7 @@ class VideoDownload {
|
||||
companion object {
|
||||
const val TAG = "VideoDownload";
|
||||
const val GROUP_PLAYLIST = "Playlist";
|
||||
const val GROUP_WATCHLATER= "WatchLater";
|
||||
|
||||
fun videoContainerToExtension(container: String): String? {
|
||||
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
|
||||
|
||||
@@ -1,46 +1,37 @@
|
||||
package com.futo.platformplayer.downloads
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.arthenica.ffmpegkit.*
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.*
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.LogCallback
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import com.arthenica.ffmpegkit.StatisticsCallback
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalSubtitleSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
|
||||
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.toHumanBitrate
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
class VideoExport {
|
||||
var state: State = State.QUEUED;
|
||||
|
||||
var videoLocal: VideoLocal;
|
||||
var videoSource: LocalVideoSource?;
|
||||
var audioSource: LocalAudioSource?;
|
||||
var subtitleSource: LocalSubtitleSource?;
|
||||
|
||||
var progress: Double = 0.0;
|
||||
var isCancelled = false;
|
||||
|
||||
var error: String? = null;
|
||||
|
||||
@kotlinx.serialization.Transient
|
||||
val onStateChanged = Event1<State>();
|
||||
@kotlinx.serialization.Transient
|
||||
val onProgressChanged = Event1<Double>();
|
||||
|
||||
fun changeState(newState: State) {
|
||||
state = newState;
|
||||
onStateChanged.emit(newState);
|
||||
}
|
||||
|
||||
constructor(videoLocal: VideoLocal, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?) {
|
||||
this.videoLocal = videoLocal;
|
||||
this.videoSource = videoSource;
|
||||
@@ -49,8 +40,6 @@ class VideoExport {
|
||||
}
|
||||
|
||||
suspend fun export(context: Context, onProgress: ((Double) -> Unit)? = null): DocumentFile = coroutineScope {
|
||||
if(isCancelled) throw CancellationException("Export got cancelled");
|
||||
|
||||
val v = videoSource;
|
||||
val a = audioSource;
|
||||
val s = subtitleSource;
|
||||
@@ -63,7 +52,7 @@ class VideoExport {
|
||||
val outputFile: DocumentFile?;
|
||||
val downloadRoot = StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set");
|
||||
if (sourceCount > 1) {
|
||||
val outputFileName = toSafeFileName(videoLocal.name) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container);
|
||||
val outputFileName = videoLocal.name.sanitizeFileName(true) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container);
|
||||
val f = downloadRoot.createFile("video/mp4", outputFileName)
|
||||
?: throw Exception("Failed to create file in external directory.");
|
||||
|
||||
@@ -79,7 +68,7 @@ class VideoExport {
|
||||
}
|
||||
outputFile = f;
|
||||
} else if (v != null) {
|
||||
val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.videoContainerToExtension(v.container);
|
||||
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container);
|
||||
val f = downloadRoot.createFile(v.container, outputFileName)
|
||||
?: throw Exception("Failed to create file in external directory.");
|
||||
|
||||
@@ -91,7 +80,7 @@ class VideoExport {
|
||||
|
||||
outputFile = f;
|
||||
} else if (a != null) {
|
||||
val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.audioContainerToExtension(a.container);
|
||||
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container);
|
||||
val f = downloadRoot.createFile(a.container, outputFileName)
|
||||
?: throw Exception("Failed to create file in external directory.");
|
||||
|
||||
@@ -106,15 +95,9 @@ class VideoExport {
|
||||
throw Exception("Cannot export when no audio or video source is set.");
|
||||
}
|
||||
|
||||
onProgressChanged.emit(100.0);
|
||||
return@coroutineScope outputFile;
|
||||
}
|
||||
|
||||
private fun toSafeFileName(input: String): String {
|
||||
val safeCharacters = ('a'..'z') + ('A'..'Z') + ('0'..'9') + listOf('-', '_')
|
||||
return input.map { if (it in safeCharacters) it else '_' }.joinToString(separator = "")
|
||||
}
|
||||
|
||||
private suspend fun combine(inputPathAudio: String?, inputPathVideo: String?, inputPathSubtitles: String?, outputPath: String, duration: Double, onProgress: ((Double) -> Unit)? = null) = withContext(Dispatchers.IO) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
//ffmpeg -i a.mp4 -i b.m4a -scodec mov_text -i c.vtt -map 0:v -map 1:a -map 2 -c:v copy -c:a copy -c:s mov_text output.mp4
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||
@@ -81,6 +82,8 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem {
|
||||
|
||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;
|
||||
override fun getPlaybackTracker(): IPlaybackTracker? = null;
|
||||
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? = null;
|
||||
|
||||
|
||||
fun toPlatformVideo() : IPlatformVideoDetails {
|
||||
throw NotImplementedError();
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.engine
|
||||
|
||||
import android.content.Context
|
||||
import com.caoccao.javet.exceptions.JavetCompilationException
|
||||
import com.caoccao.javet.exceptions.JavetException
|
||||
import com.caoccao.javet.exceptions.JavetExecutionException
|
||||
import com.caoccao.javet.interop.V8Host
|
||||
import com.caoccao.javet.interop.V8Runtime
|
||||
@@ -43,7 +44,6 @@ class V8Plugin {
|
||||
private val _clientAuth: ManagedHttpClient;
|
||||
private val _clientOthers: ConcurrentHashMap<String, JSHttpClient> = ConcurrentHashMap();
|
||||
|
||||
|
||||
val httpClient: ManagedHttpClient get() = _client;
|
||||
val httpClientAuth: ManagedHttpClient get() = _clientAuth;
|
||||
val httpClientOthers: Map<String, JSHttpClient> get() = _clientOthers;
|
||||
@@ -69,6 +69,11 @@ class V8Plugin {
|
||||
private var _busyCounter = 0;
|
||||
val isBusy get() = synchronized(_busyCounterLock) { _busyCounter > 0 };
|
||||
|
||||
var allowDevSubmit: Boolean = false
|
||||
private set(value) {
|
||||
field = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before a busy counter is about to be removed.
|
||||
* Is primarily used to prevent additional calls to dead runtimes.
|
||||
@@ -90,6 +95,10 @@ class V8Plugin {
|
||||
withDependency(getPackage(pack));
|
||||
}
|
||||
|
||||
fun changeAllowDevSubmit(isAllowed: Boolean) {
|
||||
allowDevSubmit = isAllowed;
|
||||
}
|
||||
|
||||
fun withDependency(context: Context, assetPath: String) : V8Plugin {
|
||||
if(!_deps.containsKey(assetPath))
|
||||
_deps.put(assetPath, getAssetFile(context, assetPath));
|
||||
@@ -173,8 +182,16 @@ class V8Plugin {
|
||||
isStopped = true;
|
||||
_runtime?.let {
|
||||
_runtime = null;
|
||||
if(!it.isClosed && !it.isDead)
|
||||
it.close();
|
||||
if(!it.isClosed && !it.isDead) {
|
||||
try {
|
||||
it.close();
|
||||
}
|
||||
catch(ex: JavetException) {
|
||||
//In case race conditions are going on, already closed runtimes are fine.
|
||||
if(ex.message?.contains("Runtime is already closed") != true)
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
Logger.i(TAG, "Stopped plugin [${config.name}]");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.futo.platformplayer.engine.packages
|
||||
|
||||
import com.caoccao.javet.annotations.V8Function
|
||||
import com.caoccao.javet.annotations.V8Property
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
@@ -12,6 +15,9 @@ import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class PackageBridge : V8Package {
|
||||
@Transient
|
||||
@@ -21,6 +27,7 @@ class PackageBridge : V8Package {
|
||||
@Transient
|
||||
private val _clientAuth: ManagedHttpClient
|
||||
|
||||
|
||||
override val name: String get() = "Bridge";
|
||||
override val variableName: String get() = "bridge";
|
||||
|
||||
@@ -30,6 +37,19 @@ class PackageBridge : V8Package {
|
||||
_clientAuth = plugin.httpClientAuth;
|
||||
}
|
||||
|
||||
|
||||
@V8Property
|
||||
fun buildVersion(): Int {
|
||||
//If debug build, assume max version
|
||||
if(BuildConfig.VERSION_CODE == 1)
|
||||
return Int.MAX_VALUE;
|
||||
return BuildConfig.VERSION_CODE;
|
||||
}
|
||||
@V8Property
|
||||
fun buildFlavor(): String {
|
||||
return BuildConfig.FLAVOR;
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun toast(str: String) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
@@ -47,6 +67,44 @@ class PackageBridge : V8Package {
|
||||
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", str ?: "null");
|
||||
}
|
||||
|
||||
private val _jsonSerializer = Json { this.prettyPrintIndent = " "; this.prettyPrint = true; };
|
||||
private var _devSubmitClient: ManagedHttpClient? = null;
|
||||
@V8Function
|
||||
fun devSubmit(label: String, data: String) {
|
||||
if(_plugin.config !is SourcePluginConfig)
|
||||
return;
|
||||
if(!_plugin.allowDevSubmit)
|
||||
return;
|
||||
val devUrl = _plugin.config.developerSubmitUrl ?: return;
|
||||
if(_devSubmitClient == null)
|
||||
_devSubmitClient = ManagedHttpClient();
|
||||
|
||||
val stackTrace = Thread.currentThread().stackTrace;
|
||||
val callerMethod = stackTrace.findLast {
|
||||
it.className == JSClient::class.java.name
|
||||
}?.methodName ?: "";
|
||||
val session = StateApp.instance.sessionId;
|
||||
val pluginId = _plugin.config.id;
|
||||
val pluginVersion = _plugin.config.version;
|
||||
|
||||
val obj = DevSubmitData(pluginId, pluginVersion, callerMethod, session, label, data);
|
||||
|
||||
UIDialogs.toast("DevSubmit [${callerMethod}] (${_plugin.config.name})", false);
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val json = _jsonSerializer.encodeToString(obj);
|
||||
Logger.i(TAG, "DevSubmit [${callerMethod}] - ${devUrl}\n" + json);
|
||||
val resp = _devSubmitClient?.post(devUrl, json, mutableMapOf(Pair("Content-Type", "application/json")));
|
||||
Logger.i(TAG, "DevSubmit [${callerMethod}] - ${devUrl} Status: " + (resp?.code?.toString() ?: "-1"))
|
||||
}
|
||||
catch(ex: Exception) {
|
||||
Logger.e(TAG, "DevSubmission to [${devUrl}] failed due to:\n" + ex.message, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@Serializable
|
||||
class DevSubmitData(val pluginId: String, val pluginVersion: Int, val caller: String, val session: String, val label: String, val data: String)
|
||||
|
||||
@V8Function
|
||||
fun throwTest(str: String) {
|
||||
throw IllegalStateException(str);
|
||||
|
||||
@@ -5,8 +5,11 @@ import com.caoccao.javet.annotations.V8Function
|
||||
import com.caoccao.javet.annotations.V8Property
|
||||
import com.caoccao.javet.enums.V8ConversionMode
|
||||
import com.caoccao.javet.enums.V8ProxyMode
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.engine.internal.V8BindObject
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Element
|
||||
|
||||
@@ -65,7 +68,7 @@ class PackageDOMParser : V8Package {
|
||||
return result;
|
||||
}
|
||||
@V8Property
|
||||
fun attributes(): Map<String, String> = _element.attributes().dataset();
|
||||
fun attributes(): Map<String, String> = _element.attributes().associate { Pair(it.key, it.value) }
|
||||
@V8Property
|
||||
fun innerHTML(): String = _element.html();
|
||||
@V8Property
|
||||
@@ -138,10 +141,32 @@ class PackageDOMParser : V8Package {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun toNodeTree(): SerializedNode {
|
||||
return SerializedNode(
|
||||
childNodes().map { it.toNodeTree() },
|
||||
_element.tagName(),
|
||||
_element.text(),
|
||||
attributes()
|
||||
);
|
||||
}
|
||||
@V8Function
|
||||
fun toNodeTreeJson(): String {
|
||||
return Json.encodeToString(SerializedNode.serializer(), toNodeTree());
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(parser: PackageDOMParser, str: String): DOMNode {
|
||||
return DOMNode(parser, Jsoup.parse(str));
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class SerializedNode(
|
||||
val children: List<SerializedNode>,
|
||||
val name: String,
|
||||
val value: String,
|
||||
val attributes: Map<String, String>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import com.caoccao.javet.interop.V8Runtime
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
@@ -190,6 +191,8 @@ class PackageHttp: V8Package {
|
||||
@Transient
|
||||
private val _client: ManagedHttpClient;
|
||||
|
||||
val parentConfig: IV8PluginConfig get() = _package._config;
|
||||
|
||||
@Transient
|
||||
private val _defaultHeaders = mutableMapOf<String, String>();
|
||||
@Transient
|
||||
@@ -208,28 +211,24 @@ class PackageHttp: V8Package {
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun setDefaultHeaders(defaultHeaders: Map<String, String>): PackageHttpClient {
|
||||
fun setDefaultHeaders(defaultHeaders: Map<String, String>) {
|
||||
for(pair in defaultHeaders)
|
||||
_defaultHeaders[pair.key] = pair.value;
|
||||
return this;
|
||||
}
|
||||
@V8Function
|
||||
fun setDoApplyCookies(apply: Boolean): PackageHttpClient {
|
||||
fun setDoApplyCookies(apply: Boolean) {
|
||||
if(_client is JSHttpClient)
|
||||
_client.doApplyCookies = apply;
|
||||
return this;
|
||||
}
|
||||
@V8Function
|
||||
fun setDoUpdateCookies(update: Boolean): PackageHttpClient {
|
||||
fun setDoUpdateCookies(update: Boolean) {
|
||||
if(_client is JSHttpClient)
|
||||
_client.doUpdateCookies = update;
|
||||
return this;
|
||||
}
|
||||
@V8Function
|
||||
fun setDoAllowNewCookies(allow: Boolean): PackageHttpClient {
|
||||
fun setDoAllowNewCookies(allow: Boolean) {
|
||||
if(_client is JSHttpClient)
|
||||
_client.doAllowNewCookies = allow;
|
||||
return this;
|
||||
}
|
||||
|
||||
@V8Function
|
||||
@@ -242,7 +241,8 @@ class PackageHttp: V8Package {
|
||||
val resp = client.requestMethod(method, url, headers);
|
||||
val responseBody = resp.body?.string();
|
||||
//logResponse(method, url, resp.code, resp.headers, responseBody);
|
||||
return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers));
|
||||
return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers,
|
||||
_client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -256,7 +256,8 @@ class PackageHttp: V8Package {
|
||||
val resp = client.requestMethod(method, url, body, headers);
|
||||
val responseBody = resp.body?.string();
|
||||
//logResponse(method, url, resp.code, resp.headers, responseBody);
|
||||
return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers));
|
||||
return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers,
|
||||
_client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -271,7 +272,8 @@ class PackageHttp: V8Package {
|
||||
val resp = client.get(url, headers);
|
||||
val responseBody = resp.body?.string();
|
||||
//logResponse("GET", url, resp.code, resp.headers, responseBody);
|
||||
return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers));
|
||||
return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers,
|
||||
_client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -285,7 +287,8 @@ class PackageHttp: V8Package {
|
||||
val resp = client.post(url, body, headers);
|
||||
val responseBody = resp.body?.string();
|
||||
//logResponse("POST", url, resp.code, resp.headers, responseBody);
|
||||
return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers));
|
||||
return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers,
|
||||
_client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -305,18 +308,31 @@ class PackageHttp: V8Package {
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitizeResponseHeaders(headers: Map<String, List<String>>?): Map<String, List<String>> {
|
||||
private fun sanitizeResponseHeaders(headers: Map<String, List<String>>?, onlyWhitelisted: Boolean = false): Map<String, List<String>> {
|
||||
val result = mutableMapOf<String, List<String>>()
|
||||
headers?.forEach { (header, values) ->
|
||||
val lowerCaseHeader = header.lowercase()
|
||||
if (WHITELISTED_RESPONSE_HEADERS.contains(lowerCaseHeader)) {
|
||||
result[lowerCaseHeader] = values
|
||||
if(onlyWhitelisted)
|
||||
headers?.forEach { (header, values) ->
|
||||
val lowerCaseHeader = header.lowercase()
|
||||
if (WHITELISTED_RESPONSE_HEADERS.contains(lowerCaseHeader)) {
|
||||
result[lowerCaseHeader] = values
|
||||
}
|
||||
}
|
||||
else {
|
||||
headers?.forEach { (header, values) ->
|
||||
val lowerCaseHeader = header.lowercase()
|
||||
if(lowerCaseHeader == "set-cookie") {
|
||||
result[lowerCaseHeader] = values.filter{
|
||||
!it.lowercase().contains("httponly")
|
||||
};
|
||||
}
|
||||
else
|
||||
result[lowerCaseHeader] = values;
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/*private fun logRequest(method: String, url: String, headers: Map<String, String> = HashMap(), body: String?) {
|
||||
private fun logRequest(method: String, url: String, headers: Map<String, String> = HashMap(), body: String?) {
|
||||
Logger.v(TAG) {
|
||||
val stringBuilder = StringBuilder();
|
||||
stringBuilder.appendLine("HTTP request (useAuth = )");
|
||||
@@ -333,7 +349,7 @@ class PackageHttp: V8Package {
|
||||
|
||||
return@v stringBuilder.toString();
|
||||
};
|
||||
}*/
|
||||
}
|
||||
|
||||
/*private fun logResponse(method: String, url: String, responseCode: Int? = null, responseHeaders: Map<String, List<String>> = HashMap(), responseBody: String? = null) {
|
||||
Logger.v(TAG) {
|
||||
@@ -413,7 +429,7 @@ class PackageHttp: V8Package {
|
||||
val hasClosed = socketObj.has("closed");
|
||||
val hasFailure = socketObj.has("failure");
|
||||
|
||||
//socketObj.setWeak(); //We have to manage this lifecycle
|
||||
socketObj.setWeak(); //We have to manage this lifecycle
|
||||
_listeners = socketObj;
|
||||
|
||||
_socket = _packageClient.logExceptions {
|
||||
@@ -422,8 +438,14 @@ class PackageHttp: V8Package {
|
||||
override fun open() {
|
||||
Logger.i(TAG, "Websocket opened: " + _url);
|
||||
_isOpen = true;
|
||||
if(hasOpen)
|
||||
_listeners?.invokeVoid("open", arrayOf<Any>());
|
||||
if(hasOpen) {
|
||||
try {
|
||||
_listeners?.invokeVoid("open", arrayOf<Any>());
|
||||
}
|
||||
catch(ex: Throwable){
|
||||
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] open failed: " + ex.message, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun message(msg: String) {
|
||||
if(hasMessage) {
|
||||
@@ -435,18 +457,37 @@ class PackageHttp: V8Package {
|
||||
}
|
||||
override fun closing(code: Int, reason: String) {
|
||||
if(hasClosing)
|
||||
_listeners?.invokeVoid("closing", code, reason);
|
||||
{
|
||||
try {
|
||||
_listeners?.invokeVoid("closing", code, reason);
|
||||
}
|
||||
catch(ex: Throwable){
|
||||
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closing failed: " + ex.message, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun closed(code: Int, reason: String) {
|
||||
_isOpen = false;
|
||||
if(hasClosed)
|
||||
_listeners?.invokeVoid("closed", code, reason);
|
||||
if(hasClosed) {
|
||||
try {
|
||||
_listeners?.invokeVoid("closed", code, reason);
|
||||
}
|
||||
catch(ex: Throwable){
|
||||
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun failure(exception: Throwable) {
|
||||
_isOpen = false;
|
||||
Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception);
|
||||
if(hasFailure)
|
||||
_listeners?.invokeVoid("failure", exception.message);
|
||||
if(hasFailure) {
|
||||
try {
|
||||
_listeners?.invokeVoid("failure", exception.message);
|
||||
}
|
||||
catch(ex: Throwable){
|
||||
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -456,6 +497,16 @@ class PackageHttp: V8Package {
|
||||
fun send(msg: String) {
|
||||
_socket?.send(msg);
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun close() {
|
||||
_socket?.close(1000, "");
|
||||
}
|
||||
@V8Function
|
||||
fun close(code: Int?, reason: String?) {
|
||||
_socket?.close(code ?: 1000, reason ?: "");
|
||||
_listeners?.close()
|
||||
}
|
||||
}
|
||||
|
||||
data class RequestDescriptor(
|
||||
|
||||
@@ -4,8 +4,11 @@ import android.util.Base64
|
||||
import com.caoccao.javet.annotations.V8Function
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.google.common.hash.Hashing.md5
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
|
||||
|
||||
class PackageUtilities : V8Package {
|
||||
@Transient
|
||||
private val _config: IV8PluginConfig;
|
||||
@@ -19,7 +22,31 @@ class PackageUtilities : V8Package {
|
||||
|
||||
@V8Function
|
||||
fun toBase64(arr: ByteArray): String {
|
||||
return Base64.encodeToString(arr, Base64.NO_WRAP);
|
||||
return Base64.encodeToString(arr, Base64.NO_PADDING or Base64.NO_WRAP);
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun fromBase64(str: String): ByteArray {
|
||||
return Base64.decode(str, Base64.NO_PADDING or Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun md5(arr: ByteArray): ByteArray {
|
||||
return MessageDigest.getInstance("MD5").digest(arr);
|
||||
}
|
||||
@V8Function
|
||||
fun md5String(str: String): String {
|
||||
return md5(str.toByteArray(Charsets.UTF_8)).fold("") { str, it -> str + "%02x".format(it) };
|
||||
}
|
||||
|
||||
|
||||
@V8Function
|
||||
fun sha256(arr: ByteArray): ByteArray {
|
||||
return MessageDigest.getInstance("SHA-256").digest(arr);
|
||||
}
|
||||
@V8Function
|
||||
fun sha256String(str: String): String {
|
||||
return sha256(str.toByteArray(Charsets.UTF_8)).fold("") { str, it -> str + "%02x".format(it) };
|
||||
}
|
||||
|
||||
@V8Function
|
||||
|
||||
+1
-1
@@ -114,7 +114,7 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment {
|
||||
|
||||
}
|
||||
|
||||
fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) {
|
||||
override fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) {
|
||||
_lastPolycentricProfile = polycentricProfile;
|
||||
|
||||
if (polycentricProfile == null) {
|
||||
|
||||
+12
-5
@@ -27,6 +27,7 @@ import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.exceptions.ChannelException
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.FeedView
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -59,8 +60,10 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
||||
val onChannelClicked = Event1<PlatformAuthorLink>();
|
||||
val onAddToClicked = Event1<IPlatformContent>();
|
||||
val onAddToQueueClicked = Event1<IPlatformContent>();
|
||||
val onAddToWatchLaterClicked = Event1<IPlatformContent>();
|
||||
val onLongPress = Event1<IPlatformContent>();
|
||||
|
||||
|
||||
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
|
||||
Logger.i(TAG, "getContentPager");
|
||||
|
||||
@@ -102,9 +105,9 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
||||
}).success {
|
||||
setLoading(false);
|
||||
val posBefore = _results.size;
|
||||
val toAdd = it.filter { it is IPlatformVideo }.map { it as IPlatformVideo }
|
||||
_results.addAll(toAdd);
|
||||
_adapterResults?.let { adapterVideo -> adapterVideo.notifyItemRangeInserted(adapterVideo.childToParentPosition(posBefore), toAdd.size); };
|
||||
//val toAdd = it.filter { it is IPlatformVideo }.map { it as IPlatformVideo }
|
||||
_results.addAll(it);
|
||||
_adapterResults?.let { adapterVideo -> adapterVideo.notifyItemRangeInserted(adapterVideo.childToParentPosition(posBefore), it.size); };
|
||||
}.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load next page.", it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(requireContext(), it.message ?: "", it, { loadNextPage() });
|
||||
@@ -156,6 +159,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
||||
this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit);
|
||||
this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit);
|
||||
this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit);
|
||||
this.onAddToWatchLaterClicked.subscribe(this@ChannelContentsFragment.onAddToWatchLaterClicked::emit);
|
||||
this.onLongPress.subscribe(this@ChannelContentsFragment.onLongPress::emit);
|
||||
}
|
||||
|
||||
@@ -305,7 +309,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
||||
_adapterResults?.setLoading(loading);
|
||||
}
|
||||
|
||||
fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) {
|
||||
override fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) {
|
||||
val p = _lastPolycentricProfile;
|
||||
if (p != null && polycentricProfile != null && p.system == polycentricProfile.system) {
|
||||
Logger.i(TAG, "setPolycentricProfile skipped because previous was same");
|
||||
@@ -336,8 +340,11 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
||||
context?.let {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
val channel = if(kv.value is ChannelException) (kv.value as ChannelException).channelNameOrUrl else null;
|
||||
if(jsVideoPager != null)
|
||||
UIDialogs.toast(it, "Plugin ${jsVideoPager.getPluginConfig().name} failed:\n${kv.value.message}", false);
|
||||
UIDialogs.toast(it, "Plugin ${jsVideoPager.getPluginConfig().name} failed:\n" +
|
||||
(if(!channel.isNullOrEmpty()) "(${channel}) " else "") +
|
||||
"${kv.value.message}", false);
|
||||
else
|
||||
UIDialogs.toast(it, kv.value.message ?: "", false);
|
||||
} catch (e: Throwable) {
|
||||
|
||||
+1
-1
@@ -124,7 +124,7 @@ class ChannelListFragment : Fragment, IChannelTabFragment {
|
||||
}
|
||||
}
|
||||
|
||||
fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) {
|
||||
override fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) {
|
||||
_taskLoadChannel.cancel();
|
||||
_lastPolycentricProfile = polycentricProfile;
|
||||
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ class ChannelMonetizationFragment : Fragment, IChannelTabFragment {
|
||||
_lastChannel = channel;
|
||||
}
|
||||
|
||||
fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) {
|
||||
override fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) {
|
||||
_lastPolycentricProfile = polycentricProfile
|
||||
if (polycentricProfile != null) {
|
||||
_supportView?.setPolycentricProfile(polycentricProfile)
|
||||
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
package com.futo.platformplayer.fragment.channel.tab
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPager
|
||||
import com.futo.platformplayer.api.media.structures.IAsyncPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.api.media.structures.IRefreshPager
|
||||
import com.futo.platformplayer.api.media.structures.IReplacerPager
|
||||
import com.futo.platformplayer.api.media.structures.MultiPager
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.exceptions.ChannelException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment {
|
||||
private var _recyclerResults: RecyclerView? = null
|
||||
private var _llmPlaylist: LinearLayoutManager? = null
|
||||
private var _loading = false
|
||||
private var _pagerParent: IPager<IPlatformPlaylist>? = null
|
||||
private var _pager: IPager<IPlatformPlaylist>? = null
|
||||
private var _channel: IPlatformChannel? = null
|
||||
private var _results: ArrayList<IPlatformContent> = arrayListOf()
|
||||
private var _adapterResults: InsertedViewAdapterWithLoader<ContentPreviewViewHolder>? = null
|
||||
|
||||
val onContentClicked = Event2<IPlatformContent, Long>()
|
||||
val onContentUrlClicked = Event2<String, ContentType>()
|
||||
val onUrlClicked = Event1<String>()
|
||||
val onChannelClicked = Event1<PlatformAuthorLink>()
|
||||
val onAddToClicked = Event1<IPlatformContent>()
|
||||
val onAddToQueueClicked = Event1<IPlatformContent>()
|
||||
val onAddToWatchLaterClicked = Event1<IPlatformContent>()
|
||||
val onLongPress = Event1<IPlatformContent>()
|
||||
|
||||
private fun getPlaylistPager(channel: IPlatformChannel): IPager<IPlatformPlaylist> {
|
||||
Logger.i(TAG, "getPlaylistPager")
|
||||
|
||||
return StatePlatform.instance.getChannelPlaylists(channel.url)
|
||||
}
|
||||
|
||||
private val _taskLoadPlaylists =
|
||||
TaskHandler<IPlatformChannel, IPager<IPlatformPlaylist>>({ lifecycleScope }, {
|
||||
val livePager = getPlaylistPager(it)
|
||||
return@TaskHandler livePager
|
||||
}).success { livePager ->
|
||||
setLoading(false)
|
||||
|
||||
setPager(livePager)
|
||||
}.exception<ScriptCaptchaRequiredException> { }.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load initial playlists.", it)
|
||||
UIDialogs.showGeneralRetryErrorDialog(requireContext(),
|
||||
it.message ?: "",
|
||||
it,
|
||||
{ loadNextPage() })
|
||||
}
|
||||
|
||||
private var _nextPageHandler: TaskHandler<IPager<IPlatformPlaylist>, List<IPlatformPlaylist>> =
|
||||
TaskHandler<IPager<IPlatformPlaylist>, List<IPlatformPlaylist>>({ lifecycleScope }, {
|
||||
if (it is IAsyncPager<*>) it.nextPageAsync()
|
||||
else it.nextPage()
|
||||
|
||||
processPagerExceptions(it)
|
||||
return@TaskHandler it.getResults()
|
||||
}).success {
|
||||
setLoading(false)
|
||||
val posBefore = _results.size
|
||||
_results.addAll(it)
|
||||
_adapterResults?.let { adapterResult ->
|
||||
adapterResult.notifyItemRangeInserted(
|
||||
adapterResult.childToParentPosition(
|
||||
posBefore
|
||||
), it.size
|
||||
)
|
||||
}
|
||||
}.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load next page.", it)
|
||||
UIDialogs.showGeneralRetryErrorDialog(requireContext(),
|
||||
it.message ?: "",
|
||||
it,
|
||||
{ loadNextPage() })
|
||||
}
|
||||
|
||||
private val _scrollListener = object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
super.onScrolled(recyclerView, dx, dy)
|
||||
|
||||
val recyclerResults = _recyclerResults ?: return
|
||||
val llmPlaylist = _llmPlaylist ?: return
|
||||
|
||||
val visibleItemCount = recyclerResults.childCount
|
||||
val firstVisibleItem = llmPlaylist.findFirstVisibleItemPosition()
|
||||
val visibleThreshold = 15
|
||||
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= _results.size) {
|
||||
loadNextPage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setChannel(channel: IPlatformChannel) {
|
||||
val c = _channel
|
||||
if (c != null && c.url == channel.url) {
|
||||
Logger.i(TAG, "setChannel skipped because previous was same")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.i(TAG, "setChannel setChannel=${channel}")
|
||||
|
||||
_taskLoadPlaylists.cancel()
|
||||
|
||||
_channel = channel
|
||||
_results.clear()
|
||||
_adapterResults?.notifyDataSetChanged()
|
||||
|
||||
loadInitial()
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val view = inflater.inflate(R.layout.fragment_channel_videos, container, false)
|
||||
|
||||
_recyclerResults = view.findViewById(R.id.recycler_videos)
|
||||
|
||||
_adapterResults = PreviewContentListAdapter(
|
||||
view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar
|
||||
).apply {
|
||||
this.onContentUrlClicked.subscribe(this@ChannelPlaylistsFragment.onContentUrlClicked::emit)
|
||||
this.onUrlClicked.subscribe(this@ChannelPlaylistsFragment.onUrlClicked::emit)
|
||||
this.onContentClicked.subscribe(this@ChannelPlaylistsFragment.onContentClicked::emit)
|
||||
this.onChannelClicked.subscribe(this@ChannelPlaylistsFragment.onChannelClicked::emit)
|
||||
this.onAddToClicked.subscribe(this@ChannelPlaylistsFragment.onAddToClicked::emit)
|
||||
this.onAddToQueueClicked.subscribe(this@ChannelPlaylistsFragment.onAddToQueueClicked::emit)
|
||||
this.onAddToWatchLaterClicked.subscribe(this@ChannelPlaylistsFragment.onAddToWatchLaterClicked::emit)
|
||||
this.onLongPress.subscribe(this@ChannelPlaylistsFragment.onLongPress::emit)
|
||||
}
|
||||
|
||||
_llmPlaylist = LinearLayoutManager(view.context)
|
||||
_recyclerResults?.adapter = _adapterResults
|
||||
_recyclerResults?.layoutManager = _llmPlaylist
|
||||
_recyclerResults?.addOnScrollListener(_scrollListener)
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_recyclerResults?.removeOnScrollListener(_scrollListener)
|
||||
_recyclerResults = null
|
||||
_pager = null
|
||||
|
||||
_taskLoadPlaylists.cancel()
|
||||
_nextPageHandler.cancel()
|
||||
}
|
||||
|
||||
private fun setPager(
|
||||
pager: IPager<IPlatformPlaylist>
|
||||
) {
|
||||
if (_pagerParent != null && _pagerParent is IRefreshPager<*>) {
|
||||
(_pagerParent as IRefreshPager<*>).onPagerError.remove(this)
|
||||
(_pagerParent as IRefreshPager<*>).onPagerChanged.remove(this)
|
||||
_pagerParent = null
|
||||
}
|
||||
if (_pager is IReplacerPager<*>) (_pager as IReplacerPager<*>).onReplaced.remove(this)
|
||||
|
||||
val pagerToSet: IPager<IPlatformPlaylist>?
|
||||
if (pager is IRefreshPager<*>) {
|
||||
_pagerParent = pager
|
||||
pagerToSet = pager.getCurrentPager() as IPager<IPlatformPlaylist>
|
||||
pager.onPagerChanged.subscribe(this) {
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
loadPagerInternal(it as IPager<IPlatformPlaylist>)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "loadPagerInternal failed.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
pager.onPagerError.subscribe(this) {
|
||||
Logger.e(TAG, "Search pager failed: ${it.message}", it)
|
||||
if (it is PluginException) UIDialogs.toast("Plugin [${it.config.name}] failed due to:\n${it.message}")
|
||||
else UIDialogs.toast("Plugin failed due to:\n${it.message}")
|
||||
}
|
||||
} else pagerToSet = pager
|
||||
|
||||
loadPagerInternal(pagerToSet)
|
||||
}
|
||||
|
||||
private fun loadPagerInternal(
|
||||
pager: IPager<IPlatformPlaylist>
|
||||
) {
|
||||
if (_pager is IReplacerPager<*>) (_pager as IReplacerPager<*>).onReplaced.remove(this)
|
||||
if (pager is IReplacerPager<*>) {
|
||||
pager.onReplaced.subscribe(this) { oldItem, newItem ->
|
||||
if (_pager != pager) return@subscribe
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
val toReplaceIndex = _results.indexOfFirst { it == oldItem }
|
||||
if (toReplaceIndex >= 0) {
|
||||
_results[toReplaceIndex] = newItem as IPlatformPlaylist
|
||||
_adapterResults?.let {
|
||||
it.notifyItemChanged(it.childToParentPosition(toReplaceIndex))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_pager = pager
|
||||
|
||||
processPagerExceptions(pager)
|
||||
|
||||
_results.clear()
|
||||
val toAdd = pager.getResults()
|
||||
_results.addAll(toAdd)
|
||||
_adapterResults?.notifyDataSetChanged()
|
||||
_recyclerResults?.scrollToPosition(0)
|
||||
}
|
||||
|
||||
private fun loadInitial() {
|
||||
val channel: IPlatformChannel = _channel ?: return
|
||||
setLoading(true)
|
||||
_taskLoadPlaylists.run(channel)
|
||||
}
|
||||
|
||||
private fun loadNextPage() {
|
||||
val pager: IPager<IPlatformPlaylist> = _pager ?: return
|
||||
if (_pager?.hasMorePages() == true) {
|
||||
setLoading(true)
|
||||
_nextPageHandler.run(pager)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setLoading(loading: Boolean) {
|
||||
_loading = loading
|
||||
_adapterResults?.setLoading(loading)
|
||||
}
|
||||
|
||||
private fun processPagerExceptions(pager: IPager<*>) {
|
||||
if (pager is MultiPager<*> && pager.allowFailure) {
|
||||
val ex = pager.getResultExceptions()
|
||||
for (kv in ex) {
|
||||
val jsPager: JSPager<*>? = when (kv.key) {
|
||||
is MultiPager<*> -> (kv.key as MultiPager<*>).findPager { it is JSPager<*> } as JSPager<*>?
|
||||
is JSPager<*> -> kv.key as JSPager<*>
|
||||
else -> null
|
||||
}
|
||||
|
||||
context?.let {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
val channel =
|
||||
if (kv.value is ChannelException) (kv.value as ChannelException).channelNameOrUrl else null
|
||||
if (jsPager != null) UIDialogs.toast(
|
||||
it,
|
||||
"Plugin ${jsPager.getPluginConfig().name} failed:\n" + (if (!channel.isNullOrEmpty()) "(${channel}) " else "") + "${kv.value.message}",
|
||||
false
|
||||
)
|
||||
else UIDialogs.toast(it, kv.value.message ?: "", false)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to show toast.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "PlaylistsFragment"
|
||||
fun newInstance() = ChannelPlaylistsFragment().apply { }
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -1,7 +1,11 @@
|
||||
package com.futo.platformplayer.fragment.channel.tab
|
||||
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
||||
|
||||
interface IChannelTabFragment {
|
||||
fun setChannel(channel: IPlatformChannel);
|
||||
}
|
||||
fun setChannel(channel: IPlatformChannel)
|
||||
fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -247,11 +247,14 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
val defs = currentButtonDefinitions?.toMutableList() ?: return
|
||||
val metrics = StateApp.instance.displayMetrics ?: resources.displayMetrics;
|
||||
_buttonsVisible = floor(metrics.widthPixels.toDouble() / 65.dp(resources).toDouble()).roundToInt();
|
||||
if (_buttonsVisible - 1 >= defs.size) {
|
||||
if (_buttonsVisible >= defs.size) {
|
||||
updateBottomMenuButtons(defs.toMutableList(), false);
|
||||
} else if (_buttonsVisible > 0) {
|
||||
updateBottomMenuButtons(defs.take(_buttonsVisible - 1).toMutableList(), true);
|
||||
updateMoreButtons(defs.drop(_buttonsVisible - 1).toMutableList());
|
||||
} else {
|
||||
updateBottomMenuButtons(defs.slice(IntRange(0, _buttonsVisible - 2)).toMutableList(), true);
|
||||
updateMoreButtons(defs.slice(IntRange(_buttonsVisible - 1, defs.size - 1)).toMutableList());
|
||||
updateBottomMenuButtons(mutableListOf(), false)
|
||||
updateMoreButtons(defs.toMutableList())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+31
-6
@@ -22,15 +22,17 @@ class BrowserFragment : MainFragment() {
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var _webview: WebView? = null;
|
||||
private val _webviewWithoutHandling = object: WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = inflater.inflate(R.layout.fragment_browser, container, false);
|
||||
_webview = view.findViewById<WebView?>(R.id.webview).apply {
|
||||
this.webViewClient = object: WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
this.webViewClient = _webviewWithoutHandling;
|
||||
this.settings.javaScriptEnabled = true;
|
||||
CookieManager.getInstance().setAcceptCookie(true);
|
||||
this.settings.domStorageEnabled = true;
|
||||
@@ -41,8 +43,26 @@ class BrowserFragment : MainFragment() {
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack)
|
||||
|
||||
if(parameter is String)
|
||||
if(parameter is String) {
|
||||
_webview?.webViewClient = _webviewWithoutHandling;
|
||||
_webview?.loadUrl(parameter);
|
||||
}
|
||||
else if(parameter is NavigateOptions) {
|
||||
if(parameter.urlHandlers != null && parameter.urlHandlers.isNotEmpty())
|
||||
_webview?.webViewClient = object: WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
val schema = request?.url?.scheme;
|
||||
if(schema != null && parameter.urlHandlers.containsKey(schema)) {
|
||||
parameter.urlHandlers[schema]?.invoke(request);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
else
|
||||
_webview?.webViewClient = _webviewWithoutHandling;
|
||||
_webview?.loadUrl(parameter.url);
|
||||
}
|
||||
else
|
||||
_webview?.loadUrl("about:blank");
|
||||
}
|
||||
@@ -59,4 +79,9 @@ class BrowserFragment : MainFragment() {
|
||||
companion object {
|
||||
fun newInstance() = BrowserFragment().apply {}
|
||||
}
|
||||
|
||||
class NavigateOptions(
|
||||
val url: String,
|
||||
val urlHandlers: Map<String, (WebResourceRequest)->Unit>? = null
|
||||
)
|
||||
}
|
||||
+375
-289
@@ -15,8 +15,9 @@ import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
@@ -26,26 +27,33 @@ import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.assume
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.fragment.channel.tab.ChannelAboutFragment
|
||||
import com.futo.platformplayer.fragment.channel.tab.ChannelContentsFragment
|
||||
import com.futo.platformplayer.fragment.channel.tab.ChannelListFragment
|
||||
import com.futo.platformplayer.fragment.channel.tab.ChannelMonetizationFragment
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fragment.channel.tab.IChannelTabFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.SearchType
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.selectBestImage
|
||||
import com.futo.platformplayer.selectHighestResolutionImage
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.toHumanNumber
|
||||
import com.futo.platformplayer.views.adapters.ChannelTab
|
||||
import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter
|
||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||
import com.futo.polycentric.core.*
|
||||
import com.futo.polycentric.core.OwnedClaim
|
||||
import com.futo.polycentric.core.PublicKey
|
||||
import com.futo.polycentric.core.SystemState
|
||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -54,452 +62,530 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PolycentricProfile(val system: PublicKey, val systemState: SystemState, val ownedClaims: List<OwnedClaim>);
|
||||
data class PolycentricProfile(
|
||||
val system: PublicKey, val systemState: SystemState, val ownedClaims: List<OwnedClaim>
|
||||
)
|
||||
|
||||
class ChannelFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
override val hasBottomBar: Boolean = true;
|
||||
private var _view: ChannelView? = null;
|
||||
override val isMainView: Boolean = true
|
||||
override val hasBottomBar: Boolean = true
|
||||
private var _view: ChannelView? = null
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
_view?.onShown(parameter, isBack);
|
||||
super.onShownWithView(parameter, isBack)
|
||||
_view?.onShown(parameter, isBack)
|
||||
}
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = ChannelView(this, inflater);
|
||||
_view = view;
|
||||
return view;
|
||||
override fun onCreateMainView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View {
|
||||
val view = ChannelView(this, inflater)
|
||||
_view = view
|
||||
return view
|
||||
}
|
||||
|
||||
override fun onBackPressed(): Boolean {
|
||||
return _view?.onBackPressed() ?: false;
|
||||
return _view?.onBackPressed() ?: false
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
super.onDestroyMainView();
|
||||
super.onDestroyMainView()
|
||||
|
||||
_view?.cleanup();
|
||||
_view = null;
|
||||
_view?.cleanup()
|
||||
_view = null
|
||||
}
|
||||
|
||||
fun selectTab(selectedTabIndex: Int) {
|
||||
_view?.selectTab(selectedTabIndex);
|
||||
fun selectTab(tab: ChannelTab) {
|
||||
_view?.selectTab(tab)
|
||||
}
|
||||
|
||||
@SuppressLint("ViewConstructor")
|
||||
class ChannelView : LinearLayout {
|
||||
private val _fragment: ChannelFragment;
|
||||
class ChannelView
|
||||
(fragment: ChannelFragment, inflater: LayoutInflater) : LinearLayout(inflater.context) {
|
||||
private val _fragment: ChannelFragment = fragment
|
||||
|
||||
private var _textChannel: TextView;
|
||||
private var _textChannelSub: TextView;
|
||||
private var _creatorThumbnail: CreatorThumbnail;
|
||||
private var _imageBanner: AppCompatImageView;
|
||||
private var _textChannel: TextView
|
||||
private var _textChannelSub: TextView
|
||||
private var _creatorThumbnail: CreatorThumbnail
|
||||
private var _imageBanner: AppCompatImageView
|
||||
|
||||
private var _tabs: TabLayout;
|
||||
private var _viewPager: ViewPager2;
|
||||
private var _tabLayoutMediator: TabLayoutMediator;
|
||||
private var _buttonSubscribe: SubscribeButton;
|
||||
private var _buttonSubscriptionSettings: ImageButton;
|
||||
private var _tabs: TabLayout
|
||||
private var _viewPager: ViewPager2
|
||||
|
||||
private var _overlayContainer: FrameLayout;
|
||||
private var _overlay_loading: LinearLayout;
|
||||
private var _overlay_loading_spinner: ImageView;
|
||||
// private var _adapter: ChannelViewPagerAdapter;
|
||||
private var _tabLayoutMediator: TabLayoutMediator
|
||||
private var _buttonSubscribe: SubscribeButton
|
||||
private var _buttonSubscriptionSettings: ImageButton
|
||||
|
||||
private var _slideUpOverlay: SlideUpMenuOverlay? = null;
|
||||
private var _overlayContainer: FrameLayout
|
||||
private var _overlayLoading: LinearLayout
|
||||
private var _overlayLoadingSpinner: ImageView
|
||||
|
||||
private var _isLoading: Boolean = false;
|
||||
private var _selectedTabIndex: Int = -1;
|
||||
private var _slideUpOverlay: SlideUpMenuOverlay? = null
|
||||
|
||||
private var _isLoading: Boolean = false
|
||||
private var _selectedTabIndex: Int = -1
|
||||
var channel: IPlatformChannel? = null
|
||||
private set;
|
||||
private var _url: String? = null;
|
||||
private set
|
||||
private var _url: String? = null
|
||||
|
||||
private val _onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
|
||||
super.onPageScrolled(position, positionOffset, positionOffsetPixels);
|
||||
//recalculate(position, positionOffset);
|
||||
}
|
||||
}
|
||||
private val _onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {}
|
||||
|
||||
private val _taskLoadPolycentricProfile: TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>;
|
||||
private val _taskGetChannel: TaskHandler<String, IPlatformChannel>;
|
||||
private val _taskLoadPolycentricProfile: TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>
|
||||
private val _taskGetChannel: TaskHandler<String, IPlatformChannel>
|
||||
|
||||
constructor(fragment: ChannelFragment, inflater: LayoutInflater) : super(inflater.context) {
|
||||
_fragment = fragment;
|
||||
inflater.inflate(R.layout.fragment_channel, this);
|
||||
|
||||
_taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>({fragment.lifecycleScope}, { id ->
|
||||
return@TaskHandler PolycentricCache.instance.getProfileAsync(id);
|
||||
})
|
||||
.success { it -> setPolycentricProfile(it, animate = true) }
|
||||
.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load polycentric profile.", it);
|
||||
};
|
||||
|
||||
_taskGetChannel = TaskHandler<String, IPlatformChannel>({fragment.lifecycleScope}, { url -> StatePlatform.instance.getChannelLive(url) })
|
||||
.success { showChannel(it); }
|
||||
init {
|
||||
inflater.inflate(R.layout.fragment_channel, this)
|
||||
_taskLoadPolycentricProfile =
|
||||
TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>({ fragment.lifecycleScope },
|
||||
{ id ->
|
||||
return@TaskHandler PolycentricCache.instance.getProfileAsync(id)
|
||||
}).success { setPolycentricProfile(it, animate = true) }.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load polycentric profile.", it)
|
||||
}
|
||||
_taskGetChannel = TaskHandler<String, IPlatformChannel>({ fragment.lifecycleScope },
|
||||
{ url -> StatePlatform.instance.getChannelLive(url) }).success { showChannel(it); }
|
||||
.exception<NoPlatformClientException> {
|
||||
|
||||
UIDialogs.showDialog(context,
|
||||
UIDialogs.showDialog(
|
||||
context,
|
||||
R.drawable.ic_sources,
|
||||
context.getString(R.string.no_source_enabled_to_support_this_channel) + "\n(${_url})", null, null,
|
||||
context.getString(R.string.no_source_enabled_to_support_this_channel) + "\n(${_url})",
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
UIDialogs.Action("Back", {
|
||||
fragment.close(true);
|
||||
fragment.close(true)
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
)
|
||||
}.exception<Throwable> {
|
||||
Logger.e(TAG, "Failed to load channel.", it)
|
||||
UIDialogs.showGeneralRetryErrorDialog(
|
||||
context, it.message ?: "", it, { loadChannel() }, null, fragment
|
||||
)
|
||||
}
|
||||
.exception<Throwable> {
|
||||
Logger.e(TAG, "Failed to load channel.", it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadChannel() });
|
||||
}
|
||||
|
||||
val tabs: TabLayout = findViewById(R.id.tabs);
|
||||
val viewPager: ViewPager2 = findViewById(R.id.view_pager);
|
||||
_textChannel = findViewById(R.id.text_channel_name);
|
||||
_textChannelSub = findViewById(R.id.text_metadata);
|
||||
_creatorThumbnail = findViewById(R.id.creator_thumbnail);
|
||||
_imageBanner = findViewById(R.id.image_channel_banner);
|
||||
_buttonSubscribe = findViewById(R.id.button_subscribe);
|
||||
_buttonSubscriptionSettings = findViewById(R.id.button_sub_settings);
|
||||
_overlay_loading = findViewById(R.id.channel_loading_overlay);
|
||||
_overlay_loading_spinner = findViewById(R.id.channel_loader);
|
||||
_overlayContainer = findViewById(R.id.overlay_container);
|
||||
|
||||
val tabs: TabLayout = findViewById(R.id.tabs)
|
||||
val viewPager: ViewPager2 = findViewById(R.id.view_pager)
|
||||
_textChannel = findViewById(R.id.text_channel_name)
|
||||
_textChannelSub = findViewById(R.id.text_metadata)
|
||||
_creatorThumbnail = findViewById(R.id.creator_thumbnail)
|
||||
_imageBanner = findViewById(R.id.image_channel_banner)
|
||||
_buttonSubscribe = findViewById(R.id.button_subscribe)
|
||||
_buttonSubscriptionSettings = findViewById(R.id.button_sub_settings)
|
||||
_overlayLoading = findViewById(R.id.channel_loading_overlay)
|
||||
_overlayLoadingSpinner = findViewById(R.id.channel_loader)
|
||||
_overlayContainer = findViewById(R.id.overlay_container)
|
||||
_buttonSubscribe.onSubscribed.subscribe {
|
||||
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
|
||||
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
|
||||
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer)
|
||||
_buttonSubscriptionSettings.visibility =
|
||||
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
|
||||
}
|
||||
_buttonSubscribe.onUnSubscribed.subscribe {
|
||||
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
|
||||
_buttonSubscriptionSettings.visibility =
|
||||
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
_buttonSubscriptionSettings.setOnClickListener {
|
||||
val url = channel?.url ?: _url ?: return@setOnClickListener;
|
||||
val sub = StateSubscriptions.instance.getSubscription(url) ?: return@setOnClickListener;
|
||||
UISlideOverlays.showSubscriptionOptionsOverlay(sub, _overlayContainer);
|
||||
};
|
||||
val url = channel?.url ?: _url ?: return@setOnClickListener
|
||||
val sub =
|
||||
StateSubscriptions.instance.getSubscription(url) ?: return@setOnClickListener
|
||||
UISlideOverlays.showSubscriptionOptionsOverlay(sub, _overlayContainer)
|
||||
}
|
||||
|
||||
//TODO: Determine if this is really the only solution (isSaveEnabled=false)
|
||||
viewPager.isSaveEnabled = false;
|
||||
viewPager.registerOnPageChangeCallback(_onPageChangeCallback);
|
||||
val adapter = ChannelViewPagerAdapter(fragment.childFragmentManager, fragment.lifecycle);
|
||||
viewPager.isSaveEnabled = false
|
||||
viewPager.registerOnPageChangeCallback(_onPageChangeCallback)
|
||||
val adapter = ChannelViewPagerAdapter(fragment.childFragmentManager, fragment.lifecycle)
|
||||
adapter.onChannelClicked.subscribe { c -> fragment.navigate<ChannelFragment>(c) }
|
||||
adapter.onContentClicked.subscribe { v, _ ->
|
||||
if(v is IPlatformVideo) {
|
||||
StatePlayer.instance.clearQueue();
|
||||
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail();
|
||||
} else if (v is IPlatformPlaylist) {
|
||||
fragment.navigate<PlaylistFragment>(v);
|
||||
} else if (v is IPlatformPost) {
|
||||
fragment.navigate<PostDetailFragment>(v);
|
||||
when (v) {
|
||||
is IPlatformVideo -> {
|
||||
StatePlayer.instance.clearQueue()
|
||||
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail()
|
||||
}
|
||||
|
||||
is IPlatformPlaylist -> {
|
||||
fragment.navigate<RemotePlaylistFragment>(v)
|
||||
}
|
||||
|
||||
is IPlatformPost -> {
|
||||
fragment.navigate<PostDetailFragment>(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
adapter.onAddToClicked.subscribe {content ->
|
||||
adapter.onAddToClicked.subscribe { content ->
|
||||
_overlayContainer.let {
|
||||
if(content is IPlatformVideo)
|
||||
_slideUpOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it);
|
||||
if (content is IPlatformVideo) _slideUpOverlay =
|
||||
UISlideOverlays.showVideoOptionsOverlay(content, it)
|
||||
}
|
||||
}
|
||||
adapter.onAddToQueueClicked.subscribe { content ->
|
||||
if(content is IPlatformVideo) {
|
||||
StatePlayer.instance.addToQueue(content);
|
||||
if (content is IPlatformVideo) {
|
||||
StatePlayer.instance.addToQueue(content)
|
||||
}
|
||||
}
|
||||
adapter.onAddToWatchLaterClicked.subscribe { content ->
|
||||
if (content is IPlatformVideo) {
|
||||
StatePlaylists.instance.addToWatchLater(
|
||||
SerializedPlatformVideo.fromVideo(
|
||||
content
|
||||
)
|
||||
)
|
||||
UIDialogs.toast("Added to watch later\n[${content.name}]")
|
||||
}
|
||||
}
|
||||
adapter.onUrlClicked.subscribe { url ->
|
||||
fragment.navigate<BrowserFragment>(url);
|
||||
fragment.navigate<BrowserFragment>(url)
|
||||
}
|
||||
adapter.onContentUrlClicked.subscribe { url, contentType ->
|
||||
when(contentType) {
|
||||
when (contentType) {
|
||||
ContentType.MEDIA -> {
|
||||
StatePlayer.instance.clearQueue();
|
||||
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail();
|
||||
};
|
||||
ContentType.URL -> fragment.navigate<BrowserFragment>(url);
|
||||
else -> {};
|
||||
StatePlayer.instance.clearQueue()
|
||||
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
|
||||
}
|
||||
|
||||
ContentType.URL -> fragment.navigate<BrowserFragment>(url)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
adapter.onLongPress.subscribe { content ->
|
||||
_overlayContainer.let {
|
||||
if(content is IPlatformVideo)
|
||||
_slideUpOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it);
|
||||
if (content is IPlatformVideo) _slideUpOverlay =
|
||||
UISlideOverlays.showVideoOptionsOverlay(content, it)
|
||||
}
|
||||
}
|
||||
viewPager.adapter = adapter;
|
||||
|
||||
val tabLayoutMediator = TabLayoutMediator(tabs, viewPager) { tab, position ->
|
||||
tab.text = when (position) {
|
||||
0 -> "VIDEOS"
|
||||
1 -> "CHANNELS"
|
||||
//2 -> "STORE"
|
||||
2 -> "SUPPORT"
|
||||
3 -> "ABOUT"
|
||||
else -> "Unknown $position"
|
||||
};
|
||||
};
|
||||
tabLayoutMediator.attach();
|
||||
|
||||
_tabLayoutMediator = tabLayoutMediator;
|
||||
_tabs = tabs;
|
||||
_viewPager = viewPager;
|
||||
viewPager.adapter = adapter
|
||||
val tabLayoutMediator = TabLayoutMediator(
|
||||
tabs, viewPager, (viewPager.adapter as ChannelViewPagerAdapter)::getTabNames
|
||||
)
|
||||
tabLayoutMediator.attach()
|
||||
|
||||
_tabLayoutMediator = tabLayoutMediator
|
||||
_tabs = tabs
|
||||
_viewPager = viewPager
|
||||
if (_selectedTabIndex != -1) {
|
||||
selectTab(_selectedTabIndex);
|
||||
selectTab(_selectedTabIndex)
|
||||
}
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
fun selectTab(tab: ChannelTab) {
|
||||
(_viewPager.adapter as ChannelViewPagerAdapter).getTabPosition(tab)
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
_taskLoadPolycentricProfile.cancel();
|
||||
_taskGetChannel.cancel();
|
||||
_tabLayoutMediator.detach();
|
||||
_viewPager.unregisterOnPageChangeCallback(_onPageChangeCallback);
|
||||
hideSlideUpOverlay();
|
||||
(_overlay_loading_spinner.drawable as Animatable?)?.stop();
|
||||
_taskLoadPolycentricProfile.cancel()
|
||||
_taskGetChannel.cancel()
|
||||
_tabLayoutMediator.detach()
|
||||
_viewPager.unregisterOnPageChangeCallback(_onPageChangeCallback)
|
||||
hideSlideUpOverlay()
|
||||
(_overlayLoadingSpinner.drawable as Animatable?)?.stop()
|
||||
}
|
||||
|
||||
fun onShown(parameter: Any?, isBack: Boolean) {
|
||||
hideSlideUpOverlay();
|
||||
_taskLoadPolycentricProfile.cancel();
|
||||
_selectedTabIndex = -1;
|
||||
hideSlideUpOverlay()
|
||||
_taskLoadPolycentricProfile.cancel()
|
||||
_selectedTabIndex = -1
|
||||
|
||||
if (!isBack) {
|
||||
_imageBanner.setImageDrawable(null);
|
||||
if (!isBack || _url == null) {
|
||||
_imageBanner.setImageDrawable(null)
|
||||
|
||||
if (parameter is String) {
|
||||
_buttonSubscribe.setSubscribeChannel(parameter);
|
||||
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
|
||||
setPolycentricProfileOr(parameter) {
|
||||
_textChannel.text = "";
|
||||
_textChannelSub.text = "";
|
||||
_creatorThumbnail.setThumbnail(null, true);
|
||||
Glide.with(_imageBanner)
|
||||
.clear(_imageBanner);
|
||||
};
|
||||
when (parameter) {
|
||||
is String -> {
|
||||
_buttonSubscribe.setSubscribeChannel(parameter)
|
||||
_buttonSubscriptionSettings.visibility =
|
||||
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
|
||||
setPolycentricProfileOr(parameter) {
|
||||
_textChannel.text = ""
|
||||
_textChannelSub.text = ""
|
||||
_creatorThumbnail.setThumbnail(null, true)
|
||||
Glide.with(_imageBanner).clear(_imageBanner)
|
||||
}
|
||||
|
||||
_url = parameter;
|
||||
loadChannel();
|
||||
} else if (parameter is SerializedChannel) {
|
||||
showChannel(parameter);
|
||||
_url = parameter.url;
|
||||
loadChannel();
|
||||
} else if (parameter is IPlatformChannel)
|
||||
showChannel(parameter);
|
||||
else if (parameter is PlatformAuthorLink) {
|
||||
setPolycentricProfileOr(parameter.url) {
|
||||
_textChannel.text = parameter.name;
|
||||
_textChannelSub.text = "";
|
||||
_creatorThumbnail.setThumbnail(parameter.thumbnail, true);
|
||||
Glide.with(_imageBanner)
|
||||
.clear(_imageBanner);
|
||||
_url = parameter
|
||||
loadChannel()
|
||||
}
|
||||
|
||||
loadPolycentricProfile(parameter.id, parameter.url)
|
||||
};
|
||||
is SerializedChannel -> {
|
||||
showChannel(parameter)
|
||||
_url = parameter.url
|
||||
loadChannel()
|
||||
}
|
||||
|
||||
_url = parameter.url;
|
||||
loadChannel();
|
||||
} else if (parameter is Subscription) {
|
||||
setPolycentricProfileOr(parameter.channel.url) {
|
||||
_textChannel.text = parameter.channel.name;
|
||||
_textChannelSub.text = "";
|
||||
_creatorThumbnail.setThumbnail(parameter.channel.thumbnail, true);
|
||||
Glide.with(_imageBanner)
|
||||
.clear(_imageBanner);
|
||||
is IPlatformChannel -> showChannel(parameter)
|
||||
is PlatformAuthorLink -> {
|
||||
setPolycentricProfileOr(parameter.url) {
|
||||
_textChannel.text = parameter.name
|
||||
_textChannelSub.text = ""
|
||||
_creatorThumbnail.setThumbnail(parameter.thumbnail, true)
|
||||
Glide.with(_imageBanner).clear(_imageBanner)
|
||||
|
||||
loadPolycentricProfile(parameter.channel.id, parameter.channel.url)
|
||||
};
|
||||
loadPolycentricProfile(parameter.id, parameter.url)
|
||||
}
|
||||
|
||||
_url = parameter.channel.url;
|
||||
loadChannel();
|
||||
_url = parameter.url
|
||||
loadChannel()
|
||||
}
|
||||
|
||||
is Subscription -> {
|
||||
setPolycentricProfileOr(parameter.channel.url) {
|
||||
_textChannel.text = parameter.channel.name
|
||||
_textChannelSub.text = ""
|
||||
_creatorThumbnail.setThumbnail(parameter.channel.thumbnail, true)
|
||||
Glide.with(_imageBanner).clear(_imageBanner)
|
||||
|
||||
loadPolycentricProfile(parameter.channel.id, parameter.channel.url)
|
||||
}
|
||||
|
||||
_url = parameter.channel.url
|
||||
loadChannel()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
loadChannel();
|
||||
loadChannel()
|
||||
}
|
||||
}
|
||||
|
||||
fun selectTab(selectedTabIndex: Int) {
|
||||
_selectedTabIndex = selectedTabIndex;
|
||||
_tabs.selectTab(_tabs.getTabAt(selectedTabIndex));
|
||||
private fun selectTab(selectedTabIndex: Int) {
|
||||
_selectedTabIndex = selectedTabIndex
|
||||
_tabs.selectTab(_tabs.getTabAt(selectedTabIndex))
|
||||
}
|
||||
|
||||
private fun loadPolycentricProfile(id: PlatformID, url: String) {
|
||||
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(url, true);
|
||||
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(url, true)
|
||||
if (cachedPolycentricProfile != null) {
|
||||
setPolycentricProfile(cachedPolycentricProfile, animate = true)
|
||||
if (cachedPolycentricProfile.expired) {
|
||||
_taskLoadPolycentricProfile.run(id);
|
||||
_taskLoadPolycentricProfile.run(id)
|
||||
}
|
||||
} else {
|
||||
_taskLoadPolycentricProfile.run(id);
|
||||
_taskLoadPolycentricProfile.run(id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setLoading(isLoading: Boolean) {
|
||||
if (_isLoading == isLoading) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
_isLoading = isLoading;
|
||||
if(isLoading){
|
||||
_overlay_loading.visibility = View.VISIBLE;
|
||||
(_overlay_loading_spinner.drawable as Animatable?)?.start();
|
||||
}
|
||||
else {
|
||||
(_overlay_loading_spinner.drawable as Animatable?)?.stop();
|
||||
_overlay_loading.visibility = View.GONE;
|
||||
_isLoading = isLoading
|
||||
if (isLoading) {
|
||||
_overlayLoading.visibility = View.VISIBLE
|
||||
(_overlayLoadingSpinner.drawable as Animatable?)?.start()
|
||||
} else {
|
||||
(_overlayLoadingSpinner.drawable as Animatable?)?.stop()
|
||||
_overlayLoading.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
fun onBackPressed(): Boolean {
|
||||
if (_slideUpOverlay != null) {
|
||||
hideSlideUpOverlay();
|
||||
return true;
|
||||
hideSlideUpOverlay()
|
||||
return true
|
||||
}
|
||||
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
private fun hideSlideUpOverlay() {
|
||||
_slideUpOverlay?.hide(false);
|
||||
_slideUpOverlay = null;
|
||||
_slideUpOverlay?.hide(false)
|
||||
_slideUpOverlay = null
|
||||
}
|
||||
|
||||
|
||||
private fun loadChannel() {
|
||||
val url = _url;
|
||||
val url = _url
|
||||
if (url != null) {
|
||||
setLoading(true);
|
||||
_taskGetChannel.run(url);
|
||||
setLoading(true)
|
||||
_taskGetChannel.run(url)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showChannel(channel: IPlatformChannel) {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
|
||||
_fragment.topBar?.onShown(channel);
|
||||
_fragment.topBar?.onShown(channel)
|
||||
|
||||
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) {
|
||||
UIDialogs.showConfirmationDialog(context, context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist).replace("{channelName}", channel.name), {
|
||||
UIDialogs.showDialogProgress(context) {
|
||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
StatePlaylists.instance.createPlaylistFromChannel(channel) { page ->
|
||||
_fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
it.setText("${channel.name}\n" + context.getString(R.string.page) + " $page");
|
||||
UIDialogs.showConfirmationDialog(context,
|
||||
context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist)
|
||||
.replace("{channelName}", channel.name),
|
||||
{
|
||||
UIDialogs.showDialogProgress(context) {
|
||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
StatePlaylists.instance.createPlaylistFromChannel(channel) { page ->
|
||||
_fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
it.setText("${channel.name}\n" + context.getString(R.string.page) + " $page")
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
catch(ex: Exception) {
|
||||
Logger.e(TAG, "Error", ex);
|
||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_convert_channel), ex);
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Logger.e(TAG, "Error", ex)
|
||||
UIDialogs.showGeneralErrorDialog(
|
||||
context,
|
||||
context.getString(R.string.failed_to_convert_channel),
|
||||
ex
|
||||
)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
it.hide();
|
||||
withContext(Dispatchers.Main) {
|
||||
it.hide()
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url);
|
||||
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (plugin != null && plugin.capabilities.hasSearchChannelContents) {
|
||||
buttons.add(Pair(R.drawable.ic_search) {
|
||||
_fragment.navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO, channel.url));
|
||||
});
|
||||
_fragment.navigate<SuggestionsFragment>(
|
||||
SuggestionsFragmentData(
|
||||
"", SearchType.VIDEO, channel.url
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons);
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_buttonSubscribe.setSubscribeChannel(channel);
|
||||
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
|
||||
_textChannelSub.text = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} " + context.getString(R.string.subscribers).lowercase() else "";
|
||||
_buttonSubscribe.setSubscribeChannel(channel)
|
||||
_buttonSubscriptionSettings.visibility =
|
||||
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
|
||||
_textChannel.text = channel.name
|
||||
_textChannelSub.text =
|
||||
if (channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} " + context.getString(
|
||||
R.string.subscribers
|
||||
).lowercase() else ""
|
||||
|
||||
//TODO: Find a better way to access the adapter fragments..
|
||||
val supportsPlaylists =
|
||||
StatePlatform.instance.getChannelClient(channel.url).capabilities.hasGetChannelPlaylists
|
||||
val playlistPosition = 1
|
||||
if (supportsPlaylists && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(
|
||||
ChannelTab.PLAYLISTS.ordinal.toLong()
|
||||
)
|
||||
) {
|
||||
// keep the current tab selected
|
||||
if (_viewPager.currentItem >= playlistPosition) {
|
||||
_viewPager.setCurrentItem(_viewPager.currentItem + 1, false)
|
||||
}
|
||||
|
||||
(_viewPager.adapter as ChannelViewPagerAdapter?)?.let {
|
||||
it.getFragment<ChannelContentsFragment>().setChannel(channel);
|
||||
it.getFragment<ChannelAboutFragment>().setChannel(channel);
|
||||
it.getFragment<ChannelListFragment>().setChannel(channel);
|
||||
it.getFragment<ChannelMonetizationFragment>().setChannel(channel);
|
||||
//TODO: Call on other tabs as needed
|
||||
(_viewPager.adapter as ChannelViewPagerAdapter).insert(
|
||||
playlistPosition,
|
||||
ChannelTab.PLAYLISTS
|
||||
)
|
||||
}
|
||||
if (!supportsPlaylists && (_viewPager.adapter as ChannelViewPagerAdapter).containsItem(
|
||||
ChannelTab.PLAYLISTS.ordinal.toLong()
|
||||
)
|
||||
) {
|
||||
// keep the current tab selected
|
||||
if (_viewPager.currentItem >= playlistPosition) {
|
||||
_viewPager.setCurrentItem(_viewPager.currentItem - 1, false)
|
||||
}
|
||||
|
||||
(_viewPager.adapter as ChannelViewPagerAdapter).remove(playlistPosition)
|
||||
}
|
||||
|
||||
this.channel = channel;
|
||||
// sets the channel for each tab
|
||||
for (fragment in _fragment.childFragmentManager.fragments) {
|
||||
(fragment as IChannelTabFragment).setChannel(channel)
|
||||
}
|
||||
|
||||
(_viewPager.adapter as ChannelViewPagerAdapter).channel = channel
|
||||
|
||||
|
||||
_viewPager.adapter!!.notifyDataSetChanged()
|
||||
|
||||
this.channel = channel
|
||||
|
||||
setPolycentricProfileOr(channel.url) {
|
||||
_textChannel.text = channel.name;
|
||||
_creatorThumbnail.setThumbnail(channel.thumbnail, true);
|
||||
Glide.with(_imageBanner)
|
||||
.load(channel.banner)
|
||||
.crossfade()
|
||||
.into(_imageBanner);
|
||||
_textChannel.text = channel.name
|
||||
_creatorThumbnail.setThumbnail(channel.thumbnail, true)
|
||||
Glide.with(_imageBanner).load(channel.banner).crossfade().into(_imageBanner)
|
||||
|
||||
_taskLoadPolycentricProfile.run(channel.id);
|
||||
};
|
||||
_taskLoadPolycentricProfile.run(channel.id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPolycentricProfileOr(url: String, or: () -> Unit) {
|
||||
setPolycentricProfile(null, animate = false);
|
||||
setPolycentricProfile(null, animate = false)
|
||||
|
||||
val cachedProfile = channel?.let { PolycentricCache.instance.getCachedProfile(url) };
|
||||
val cachedProfile = channel?.let { PolycentricCache.instance.getCachedProfile(url) }
|
||||
if (cachedProfile != null) {
|
||||
setPolycentricProfile(cachedProfile, animate = false);
|
||||
setPolycentricProfile(cachedProfile, animate = false)
|
||||
} else {
|
||||
or();
|
||||
or()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
|
||||
val dp_35 = 35.dp(resources)
|
||||
val profile = cachedPolycentricProfile?.profile;
|
||||
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_35 * dp_35)
|
||||
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
|
||||
|
||||
if (avatar != null) {
|
||||
_creatorThumbnail.setThumbnail(avatar, animate);
|
||||
} else {
|
||||
_creatorThumbnail.setThumbnail(channel?.thumbnail, animate);
|
||||
_creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
|
||||
private fun setPolycentricProfile(
|
||||
cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean
|
||||
) {
|
||||
val dp35 = 35.dp(resources)
|
||||
val profile = cachedPolycentricProfile?.profile
|
||||
val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35)?.let {
|
||||
it.toURLInfoSystemLinkUrl(
|
||||
profile.system.toProto(), it.process, profile.systemState.servers.toList()
|
||||
)
|
||||
}
|
||||
|
||||
val banner = profile?.systemState?.banner?.selectHighestResolutionImage()
|
||||
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
|
||||
if (avatar != null) {
|
||||
_creatorThumbnail.setThumbnail(avatar, animate)
|
||||
} else {
|
||||
_creatorThumbnail.setThumbnail(channel?.thumbnail, animate)
|
||||
_creatorThumbnail.setHarborAvailable(
|
||||
profile != null, animate, profile?.system?.toProto()
|
||||
)
|
||||
}
|
||||
|
||||
val banner = profile?.systemState?.banner?.selectHighestResolutionImage()?.let {
|
||||
it.toURLInfoSystemLinkUrl(
|
||||
profile.system.toProto(), it.process, profile.systemState.servers.toList()
|
||||
)
|
||||
}
|
||||
|
||||
if (banner != null) {
|
||||
Glide.with(_imageBanner)
|
||||
.load(banner)
|
||||
.crossfade()
|
||||
.into(_imageBanner);
|
||||
Glide.with(_imageBanner).load(banner).crossfade().into(_imageBanner)
|
||||
} else {
|
||||
Glide.with(_imageBanner)
|
||||
.load(channel?.banner)
|
||||
.crossfade()
|
||||
.into(_imageBanner);
|
||||
Glide.with(_imageBanner).load(channel?.banner).crossfade().into(_imageBanner)
|
||||
}
|
||||
|
||||
if (profile != null) {
|
||||
_fragment.topBar?.onShown(profile);
|
||||
_textChannel.text = profile.systemState.username;
|
||||
_fragment.topBar?.onShown(profile)
|
||||
_textChannel.text = profile.systemState.username
|
||||
}
|
||||
|
||||
(_viewPager.adapter as ChannelViewPagerAdapter?)?.let {
|
||||
it.getFragment<ChannelAboutFragment>().setPolycentricProfile(profile);
|
||||
it.getFragment<ChannelMonetizationFragment>().setPolycentricProfile(profile);
|
||||
it.getFragment<ChannelListFragment>().setPolycentricProfile(profile);
|
||||
it.getFragment<ChannelContentsFragment>().setPolycentricProfile(profile);
|
||||
//TODO: Call on other tabs as needed
|
||||
// sets the profile for each tab
|
||||
for (fragment in _fragment.childFragmentManager.fragments) {
|
||||
(fragment as IChannelTabFragment).setPolycentricProfile(profile)
|
||||
}
|
||||
|
||||
val insertPosition = 1
|
||||
|
||||
//TODO only add channels and support if its setup on the polycentric profile
|
||||
if (profile != null && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(
|
||||
ChannelTab.SUPPORT.ordinal.toLong()
|
||||
)
|
||||
) {
|
||||
(_viewPager.adapter as ChannelViewPagerAdapter).insert(insertPosition, ChannelTab.SUPPORT)
|
||||
}
|
||||
if (profile != null && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(
|
||||
ChannelTab.CHANNELS.ordinal.toLong()
|
||||
)
|
||||
) {
|
||||
(_viewPager.adapter as ChannelViewPagerAdapter).insert(insertPosition, ChannelTab.CHANNELS)
|
||||
}
|
||||
(_viewPager.adapter as ChannelViewPagerAdapter).profile = profile
|
||||
_viewPager.adapter!!.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "ChannelFragment";
|
||||
const val TAG = "ChannelFragment"
|
||||
fun newInstance() = ChannelFragment().apply { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -1,7 +1,9 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.Browser
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -117,6 +119,8 @@ class CommentsFragment : MainFragment() {
|
||||
val holder = CommentWithReferenceViewHolder(viewGroup, _cache);
|
||||
holder.onDelete.subscribe(::onDelete);
|
||||
holder.onRepliesClick.subscribe(::onRepliesClick);
|
||||
holder.onClick.subscribe(::onClick);
|
||||
holder.onAuthorClick.subscribe(::onAuthorClick);
|
||||
return@InsertedViewAdapterWithLoader holder;
|
||||
}
|
||||
);
|
||||
@@ -200,6 +204,30 @@ class CommentsFragment : MainFragment() {
|
||||
return false
|
||||
}
|
||||
|
||||
private fun onClick(c: IPlatformComment) {
|
||||
if (c !is PolycentricPlatformComment) {
|
||||
return
|
||||
}
|
||||
|
||||
val parentRef = c.parentReference
|
||||
if (parentRef != null && _repliesOverlay.handleParentClick(c.contextUrl, parentRef)) {
|
||||
setRepliesOverlayVisible(true, true)
|
||||
}
|
||||
}
|
||||
private fun onAuthorClick(c: IPlatformComment) {
|
||||
if (c !is PolycentricPlatformComment) {
|
||||
return@onAuthorClick;
|
||||
}
|
||||
|
||||
Logger.i(TAG, "onAuthorClick: " + c.author.id.value);
|
||||
if(c.author.id.value?.startsWith("polycentric://") ?: false) {
|
||||
//val navUrl = "https://harbor.social/" + c.author.id.value?.substring("polycentric://".length);
|
||||
val navUrl = "https://polycentric.io/user/" + c.author.id.value?.substring("polycentric://".length);
|
||||
_fragment.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl)))
|
||||
//_fragment.navigate<BrowserFragment>(navUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRepliesClick(c: IPlatformComment) {
|
||||
val replyCount = c.replyCount ?: 0;
|
||||
var metadata = "";
|
||||
|
||||
+18
-5
@@ -6,26 +6,32 @@ import android.view.LayoutInflater
|
||||
import android.widget.LinearLayout
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.structures.*
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.video.PlayerManager
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
|
||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewHolder
|
||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
|
||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewNestedVideoViewHolder
|
||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoViewHolder
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||
import com.futo.platformplayer.withTimestamp
|
||||
import kotlin.math.floor
|
||||
|
||||
abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent, IPlatformContent, IPager<IPlatformContent>, ContentPreviewViewHolder> where TFragment : MainFragment {
|
||||
@@ -81,6 +87,12 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
StatePlayer.instance.addToQueue(it);
|
||||
}
|
||||
};
|
||||
adapter.onAddToWatchLaterClicked.subscribe(this) {
|
||||
if(it is IPlatformVideo) {
|
||||
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it));
|
||||
UIDialogs.toast("Added to watch later\n[${it.name}]");
|
||||
}
|
||||
};
|
||||
adapter.onLongPress.subscribe(this) {
|
||||
if (it is IPlatformVideo) {
|
||||
showVideoOptionsOverlay(it)
|
||||
@@ -135,6 +147,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
adapter.onChannelClicked.remove(this);
|
||||
adapter.onAddToClicked.remove(this);
|
||||
adapter.onAddToQueueClicked.remove(this);
|
||||
adapter.onAddToWatchLaterClicked.remove(this);
|
||||
adapter.onLongPress.remove(this);
|
||||
}
|
||||
|
||||
@@ -174,7 +187,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
fragment.navigate<VideoDetailFragment>(content).maximizeVideoDetail();
|
||||
}
|
||||
} else if (content is IPlatformPlaylist) {
|
||||
fragment.navigate<PlaylistFragment>(content);
|
||||
fragment.navigate<RemotePlaylistFragment>(content);
|
||||
} else if (content is IPlatformPost) {
|
||||
fragment.navigate<PostDetailFragment>(content);
|
||||
}
|
||||
@@ -185,7 +198,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
StatePlayer.instance.clearQueue();
|
||||
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail();
|
||||
};
|
||||
ContentType.PLAYLIST -> fragment.navigate<PlaylistFragment>(url);
|
||||
ContentType.PLAYLIST -> fragment.navigate<RemotePlaylistFragment>(url);
|
||||
ContentType.URL -> fragment.navigate<BrowserFragment>(url);
|
||||
else -> {};
|
||||
}
|
||||
|
||||
+15
-5
@@ -99,7 +99,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
|
||||
.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load results.", it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() }, null, fragment);
|
||||
}
|
||||
|
||||
setPreviewsEnabled(Settings.instance.search.previewFeedItems);
|
||||
@@ -129,7 +129,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
onFilterClick.subscribe(this) {
|
||||
_overlayContainer.let {
|
||||
val filterValuesCopy = HashMap(_filterValues);
|
||||
val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy);
|
||||
val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy, _channelUrl != null);
|
||||
filtersOverlay.onOK.subscribe { enabledClientIds, changed ->
|
||||
if (changed) {
|
||||
setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy);
|
||||
@@ -154,8 +154,14 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
};
|
||||
|
||||
onSearch.subscribe(this) {
|
||||
if(it.isHttpUrl())
|
||||
navigate<VideoDetailFragment>(it);
|
||||
if(it.isHttpUrl()) {
|
||||
if(StatePlatform.instance.hasEnabledPlaylistClient(it))
|
||||
navigate<RemotePlaylistFragment>(it);
|
||||
else if(StatePlatform.instance.hasEnabledChannelClient(it))
|
||||
navigate<ChannelFragment>(it);
|
||||
else
|
||||
navigate<VideoDetailFragment>(it);
|
||||
}
|
||||
else
|
||||
setQuery(it, true);
|
||||
};
|
||||
@@ -164,7 +170,11 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val commonCapabilities = StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
val commonCapabilities =
|
||||
if(_channelUrl == null)
|
||||
StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
else
|
||||
StatePlatform.instance.getCommonSearchChannelContentsCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
val sorts = commonCapabilities?.sorts ?: listOf();
|
||||
if (sorts.size > 1) {
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ class CreatorSearchResultsFragment : MainFragment() {
|
||||
.exception<ScriptCaptchaRequiredException> { }
|
||||
.exception<Throwable> {
|
||||
Logger.w(ChannelFragment.TAG, "Failed to load results.", it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() }, null, fragment);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-19
@@ -8,18 +8,21 @@ import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.downloads.VideoDownload
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.toHumanBytesSize
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
|
||||
import com.futo.platformplayer.views.others.ProgressBar
|
||||
import com.futo.platformplayer.views.adapters.viewholders.VideoDownloadViewHolder
|
||||
import com.futo.platformplayer.views.items.ActiveDownloadItem
|
||||
import com.futo.platformplayer.views.items.PlaylistDownloadItem
|
||||
import com.futo.platformplayer.views.others.ProgressBar
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -62,16 +65,6 @@ class DownloadsFragment : MainFragment() {
|
||||
}
|
||||
}
|
||||
};
|
||||
StateDownloads.instance.onExportsChanged.subscribe(this) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
Logger.i(TAG, "Reloading UI for exports");
|
||||
_view?.reloadUI()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to reload UI for exports", e)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
@@ -79,7 +72,6 @@ class DownloadsFragment : MainFragment() {
|
||||
|
||||
StateDownloads.instance.onDownloadsChanged.remove(this);
|
||||
StateDownloads.instance.onDownloadedChanged.remove(this);
|
||||
StateDownloads.instance.onExportsChanged.remove(this);
|
||||
}
|
||||
|
||||
private class DownloadsView : LinearLayout {
|
||||
@@ -143,6 +135,7 @@ class DownloadsFragment : MainFragment() {
|
||||
|
||||
val activeDownloads = StateDownloads.instance.getDownloading();
|
||||
val playlists = StateDownloads.instance.getCachedPlaylists();
|
||||
val watchLaterDownload = StateDownloads.instance.getWatchLaterDescriptor();
|
||||
val downloaded = StateDownloads.instance.getDownloadedVideos()
|
||||
.filter { it.groupType != VideoDownload.GROUP_PLAYLIST || it.groupID == null || !StateDownloads.instance.hasCachedPlaylist(it.groupID!!) };
|
||||
|
||||
@@ -150,23 +143,35 @@ class DownloadsFragment : MainFragment() {
|
||||
_listActiveDownloadsContainer.visibility = GONE;
|
||||
else {
|
||||
_listActiveDownloadsContainer.visibility = VISIBLE;
|
||||
_listActiveDownloadsMeta.text = "(${activeDownloads.size})";
|
||||
_listActiveDownloadsMeta.text = "(${activeDownloads.size} videos)";
|
||||
|
||||
_listActiveDownloads.removeAllViews();
|
||||
for(view in activeDownloads.map { ActiveDownloadItem(context, it, _frag.lifecycleScope) })
|
||||
for(view in activeDownloads.take(4).map { ActiveDownloadItem(context, it, _frag.lifecycleScope) })
|
||||
_listActiveDownloads.addView(view);
|
||||
}
|
||||
|
||||
if(playlists.isEmpty())
|
||||
if(playlists.isEmpty() && watchLaterDownload == null)
|
||||
_listPlaylistsContainer.visibility = GONE;
|
||||
else {
|
||||
_listPlaylistsContainer.visibility = VISIBLE;
|
||||
_listPlaylistsMeta.text = "(${playlists.size} ${context.getString(R.string.playlists).lowercase()}, ${playlists.sumOf { it.playlist.videos.size }} ${context.getString(R.string.videos).lowercase()})";
|
||||
|
||||
val watchLater = if(watchLaterDownload != null) StatePlaylists.instance.getWatchLater() else listOf();
|
||||
|
||||
_listPlaylistsMeta.text = "(${playlists.size + (if(watchLaterDownload != null) 1 else 0)} ${context.getString(R.string.playlists).lowercase()}, ${playlists.sumOf { it.playlist.videos.size } + watchLater.size} ${context.getString(R.string.videos).lowercase()})";
|
||||
|
||||
_listPlaylists.removeAllViews();
|
||||
for(view in playlists.map { PlaylistDownloadItem(context, it) }) {
|
||||
if(watchLaterDownload != null) {
|
||||
val pdView = PlaylistDownloadItem(context, "Watch Later", watchLater.firstOrNull()?.thumbnails?.getHQThumbnail(), "WATCHLATER");
|
||||
pdView.setOnClickListener {
|
||||
_frag.navigate<WatchLaterFragment>();
|
||||
}
|
||||
_listPlaylists.addView(pdView);
|
||||
}
|
||||
for(view in playlists.map { PlaylistDownloadItem(context, it.playlist.name, it.playlist.videos.firstOrNull()?.thumbnails?.getHQThumbnail(), it.playlist) }) {
|
||||
view.setOnClickListener {
|
||||
_frag.navigate<PlaylistFragment>(view.playlist.playlist);
|
||||
if(view.obj is Playlist) {
|
||||
_frag.navigate<PlaylistFragment>(view.obj);
|
||||
}
|
||||
};
|
||||
_listPlaylists.addView(view);
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
Logger.w(TAG, "Failed to load next page.", it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
|
||||
loadNextPage();
|
||||
});
|
||||
}, null, fragment);
|
||||
//UIDialogs.showDataRetryDialog(layoutInflater, it.message, { loadNextPage() });
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -174,7 +174,7 @@ class HistoryFragment : MainFragment() {
|
||||
Logger.w(TAG, "Failed to load next page.", it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
|
||||
loadNextPage();
|
||||
});
|
||||
}, null, fragment);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+50
-3
@@ -8,6 +8,7 @@ import android.view.ViewGroup
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
@@ -17,15 +18,23 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.SearchType
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.NoResultsView
|
||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewHolder
|
||||
import com.futo.platformplayer.views.announcements.AnnouncementView
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@@ -117,10 +126,10 @@ class HomeFragment : MainFragment() {
|
||||
Logger.w(TAG, "Failed to load channel.", it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_get_home), it, {
|
||||
loadResults()
|
||||
}) {
|
||||
}, {
|
||||
finishRefreshLayoutLoader();
|
||||
setLoading(false);
|
||||
};
|
||||
}, fragment);
|
||||
};
|
||||
|
||||
setPreviewsEnabled(Settings.instance.home.previewFeedItems);
|
||||
@@ -147,6 +156,42 @@ class HomeFragment : MainFragment() {
|
||||
finishRefreshLayoutLoader();
|
||||
}
|
||||
|
||||
override fun getEmptyPagerView(): View? {
|
||||
val dp10 = 10.dp(resources);
|
||||
val dp30 = 30.dp(resources);
|
||||
|
||||
val pluginsExist = StatePlatform.instance.getAvailableClients().isNotEmpty();
|
||||
if(StatePlatform.instance.getEnabledClients().isEmpty())
|
||||
//Initial setup
|
||||
return NoResultsView(context, "No enabled sources", if(pluginsExist)
|
||||
"Enable or install some sources"
|
||||
else "This Grayjay version comes without any sources, install sources externally or using the button below.", R.drawable.ic_sources,
|
||||
listOf(BigButton(context, "Browse Online Sources", "View official sources online", R.drawable.ic_explore) {
|
||||
fragment.navigate<BrowserFragment>(BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
|
||||
Pair("grayjay") { req ->
|
||||
StateApp.instance.contextOrNull?.let {
|
||||
if(it is MainActivity) {
|
||||
runBlocking {
|
||||
it.handleUrlAll(req.url.toString());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
)));
|
||||
}.withMargin(dp10, dp30),
|
||||
if(pluginsExist) BigButton(context, "Sources", "Go to the sources tab", R.drawable.ic_creators) {
|
||||
fragment.navigate<SourcesFragment>();
|
||||
}.withMargin(dp10, dp30) else null).filterNotNull()
|
||||
);
|
||||
else
|
||||
return NoResultsView(context, "Nothing to see here", "The enabled sources do not have any results.", R.drawable.ic_help,
|
||||
listOf(BigButton(context, "Sources", "Go to the sources tab", R.drawable.ic_creators) {
|
||||
fragment.navigate<SourcesFragment>();
|
||||
}.withMargin(dp10, dp30))
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
override fun reload() {
|
||||
loadResults();
|
||||
}
|
||||
@@ -161,13 +206,15 @@ class HomeFragment : MainFragment() {
|
||||
}
|
||||
private fun loadedResult(pager : IPager<IPlatformContent>) {
|
||||
if (pager is EmptyPager<IPlatformContent>) {
|
||||
StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), context.getString(R.string.no_home_available), context.getString(R.string.no_home_page_is_available_please_check_if_you_are_connected_to_the_internet_and_refresh), AnnouncementType.SESSION);
|
||||
//StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), context.getString(R.string.no_home_available), context.getString(R.string.no_home_page_is_available_please_check_if_you_are_connected_to_the_internet_and_refresh), AnnouncementType.SESSION);
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Got new home pager ${pager}");
|
||||
finishRefreshLayoutLoader();
|
||||
setLoading(false);
|
||||
setPager(pager);
|
||||
if(pager.getResults().isEmpty() && !pager.hasMorePages())
|
||||
setEmptyPager(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+33
-8
@@ -12,16 +12,21 @@ import android.widget.TextView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.views.AnyAdapterView
|
||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||
import com.futo.platformplayer.views.adapters.viewholders.ImportPlaylistsViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ImportPlaylistsFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
@@ -67,7 +72,7 @@ class ImportPlaylistsFragment : MainFragment() {
|
||||
private val _items: ArrayList<SelectablePlaylist> = arrayListOf();
|
||||
private var _currentLoadIndex = 0;
|
||||
|
||||
private var _taskLoadPlaylist: TaskHandler<String, Playlist?>;
|
||||
private var _taskLoadPlaylist: TaskHandler<String, IPlatformPlaylistDetails?>;
|
||||
|
||||
constructor(fragment: ImportPlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) {
|
||||
_fragment = fragment;
|
||||
@@ -102,7 +107,7 @@ class ImportPlaylistsFragment : MainFragment() {
|
||||
|
||||
setLoading(false);
|
||||
|
||||
_taskLoadPlaylist = TaskHandler<String, Playlist?>({fragment.lifecycleScope}, { link -> StatePlatform.instance.getPlaylist(link).toPlaylist(); })
|
||||
_taskLoadPlaylist = TaskHandler<String, IPlatformPlaylistDetails?>({fragment.lifecycleScope}, { link -> StatePlatform.instance.getPlaylist(link); })
|
||||
.success {
|
||||
if (it != null) {
|
||||
_items.add(SelectablePlaylist(it));
|
||||
@@ -113,7 +118,7 @@ class ImportPlaylistsFragment : MainFragment() {
|
||||
}.exceptionWithParameter<Throwable> { ex, para ->
|
||||
//setLoading(false);
|
||||
Logger.w(ChannelFragment.TAG, "Failed to load results.", ex);
|
||||
UIDialogs.toast(context, context.getString(R.string.failed_to_fetch) + "\n${para}", false)
|
||||
UIDialogs.appToast(context.getString(R.string.failed_to_fetch) + "\n${para}\n" + ex.message, false)
|
||||
//UIDialogs.showDataRetryDialog(layoutInflater, { load(); });
|
||||
loadNext();
|
||||
};
|
||||
@@ -147,12 +152,32 @@ class ImportPlaylistsFragment : MainFragment() {
|
||||
it.title = context.getString(R.string.import_playlists);
|
||||
it.onImport.subscribe(this) {
|
||||
val playlistsToImport = _items.filter { i -> i.selected }.toList();
|
||||
for (playlistToImport in playlistsToImport) {
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(playlistToImport.playlist);
|
||||
}
|
||||
|
||||
UIDialogs.toast("${playlistsToImport.size} " + context.getString(R.string.playlists_imported));
|
||||
_fragment.closeSegment();
|
||||
UIDialogs.showDialogProgress(context) {
|
||||
it.setText("Importing playlists..");
|
||||
it.setProgress(0f);
|
||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
for ((i, playlistToImport) in playlistsToImport.withIndex()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
it.setText("Importing playlists..\n[${playlistToImport.playlist.name}]");
|
||||
}
|
||||
try {
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(playlistToImport.playlist.toPlaylist());
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
UIDialogs.appToast("Failed to import [${playlistToImport.playlist.name}]\n" + ex.message);
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
it.setProgress(i.toDouble() / playlistsToImport.size);
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast("${playlistsToImport.size} " + context.getString(R.string.playlists_imported));
|
||||
_fragment.closeSegment();
|
||||
it.dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+60
-128
@@ -1,14 +1,11 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.core.view.setPadding
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
@@ -30,7 +27,6 @@ import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class PlaylistFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
@@ -70,7 +66,6 @@ class PlaylistFragment : MainFragment() {
|
||||
private val _fragment: PlaylistFragment;
|
||||
|
||||
private var _playlist: Playlist? = null;
|
||||
private var _remotePlaylist: IPlatformPlaylistDetails? = null;
|
||||
private var _editPlaylistNameInput: SlideUpMenuTextInput? = null;
|
||||
private var _editPlaylistOverlay: SlideUpMenuOverlay? = null;
|
||||
private var _url: String? = null;
|
||||
@@ -136,79 +131,86 @@ class PlaylistFragment : MainFragment() {
|
||||
return@TaskHandler StatePlatform.instance.getPlaylist(it);
|
||||
})
|
||||
.success {
|
||||
setLoading(false);
|
||||
_remotePlaylist = it;
|
||||
setName(it.name);
|
||||
setVideos(it.contents.getResults(), false);
|
||||
setVideoCount(it.videoCount);
|
||||
//TODO: Implement support for pagination
|
||||
setVideos(it.toPlaylist().videos, false);
|
||||
setVideoCount(it.videoCount);
|
||||
setLoading(false);
|
||||
}
|
||||
.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load playlist.", it);
|
||||
val c = context ?: return@exception;
|
||||
UIDialogs.showGeneralRetryErrorDialog(c, context.getString(R.string.failed_to_load_playlist), it, ::fetchPlaylist);
|
||||
UIDialogs.showGeneralRetryErrorDialog(c, context.getString(R.string.failed_to_load_playlist), it, ::fetchPlaylist, null, fragment);
|
||||
};
|
||||
}
|
||||
|
||||
fun onShown(parameter: Any?) {
|
||||
_taskLoadPlaylist.cancel();
|
||||
_taskLoadPlaylist.cancel()
|
||||
|
||||
if (parameter is Playlist?) {
|
||||
_playlist = parameter;
|
||||
_remotePlaylist = null;
|
||||
_url = null;
|
||||
_playlist = parameter
|
||||
_url = null
|
||||
|
||||
if(parameter != null) {
|
||||
setName(parameter.name);
|
||||
setVideos(parameter.videos, true);
|
||||
setVideoCount(parameter.videos.size);
|
||||
setButtonDownloadVisible(true);
|
||||
setButtonEditVisible(true);
|
||||
if (parameter != null) {
|
||||
setName(parameter.name)
|
||||
setVideos(parameter.videos, true)
|
||||
setVideoCount(parameter.videos.size)
|
||||
setButtonDownloadVisible(true)
|
||||
setButtonEditVisible(true)
|
||||
|
||||
if (!StatePlaylists.instance.playlistStore.getItems().contains(parameter)) {
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()
|
||||
?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) {
|
||||
StatePlaylists.instance.playlistStore.save(parameter)
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(
|
||||
arrayListOf()
|
||||
)
|
||||
UIDialogs.toast("Playlist saved")
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
setName(null);
|
||||
setVideos(null, false);
|
||||
setVideoCount(-1);
|
||||
setButtonDownloadVisible(false);
|
||||
setButtonEditVisible(false);
|
||||
setName(null)
|
||||
setVideos(null, false)
|
||||
setVideoCount(-1)
|
||||
setButtonDownloadVisible(false)
|
||||
setButtonEditVisible(false)
|
||||
}
|
||||
|
||||
//TODO: Do I have to remove the showConvertPlaylistButton(); button here?
|
||||
} else if (parameter is IPlatformPlaylist) {
|
||||
_playlist = null;
|
||||
_remotePlaylist = null;
|
||||
_url = parameter.url;
|
||||
_playlist = null
|
||||
_url = parameter.url
|
||||
|
||||
setVideoCount(parameter.videoCount);
|
||||
setName(parameter.name);
|
||||
setVideos(null, false);
|
||||
setButtonDownloadVisible(false);
|
||||
setButtonEditVisible(false);
|
||||
setVideoCount(parameter.videoCount)
|
||||
setName(parameter.name)
|
||||
setVideos(null, false)
|
||||
setButtonDownloadVisible(false)
|
||||
setButtonEditVisible(false)
|
||||
|
||||
fetchPlaylist();
|
||||
showConvertPlaylistButton();
|
||||
fetchPlaylist()
|
||||
} else if (parameter is String) {
|
||||
_playlist = null;
|
||||
_remotePlaylist = null;
|
||||
_url = parameter;
|
||||
_playlist = null
|
||||
_url = parameter
|
||||
|
||||
setName(null);
|
||||
setVideos(null, false);
|
||||
setVideoCount(-1);
|
||||
setButtonDownloadVisible(false);
|
||||
setButtonEditVisible(false);
|
||||
setName(null)
|
||||
setVideos(null, false)
|
||||
setVideoCount(-1)
|
||||
setButtonDownloadVisible(false)
|
||||
setButtonEditVisible(false)
|
||||
|
||||
fetchPlaylist();
|
||||
showConvertPlaylistButton();
|
||||
fetchPlaylist()
|
||||
}
|
||||
|
||||
updateDownloadState();
|
||||
_playlist?.let {
|
||||
updateDownloadState(VideoDownload.GROUP_PLAYLIST, it.id, this::download)
|
||||
}
|
||||
}
|
||||
|
||||
fun onResume() {
|
||||
StateDownloads.instance.onDownloadsChanged.subscribe(this) {
|
||||
_fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
updateDownloadState();
|
||||
_playlist?.let {
|
||||
updateDownloadState(VideoDownload.GROUP_PLAYLIST, it.id, this@PlaylistView::download);
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to update download state onDownloadedChanged.")
|
||||
}
|
||||
@@ -217,7 +219,9 @@ class PlaylistFragment : MainFragment() {
|
||||
StateDownloads.instance.onDownloadedChanged.subscribe(this) {
|
||||
_fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
updateDownloadState();
|
||||
_playlist?.let {
|
||||
updateDownloadState(VideoDownload.GROUP_PLAYLIST, it.id, this@PlaylistView::download);
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to update download state onDownloadedChanged.")
|
||||
}
|
||||
@@ -225,39 +229,17 @@ class PlaylistFragment : MainFragment() {
|
||||
};
|
||||
}
|
||||
|
||||
private fun download() {
|
||||
_playlist?.let {
|
||||
UISlideOverlays.showDownloadPlaylistOverlay(it, overlayContainer);
|
||||
}
|
||||
}
|
||||
|
||||
fun onPause() {
|
||||
StateDownloads.instance.onDownloadsChanged.remove(this);
|
||||
StateDownloads.instance.onDownloadedChanged.remove(this);
|
||||
}
|
||||
|
||||
private fun showConvertPlaylistButton() {
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) {
|
||||
val remotePlaylist = _remotePlaylist;
|
||||
if (remotePlaylist == null) {
|
||||
UIDialogs.toast(context.getString(R.string.please_wait_for_playlist_to_finish_loading));
|
||||
return@Pair;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
StatePlaylists.instance.playlistStore.save(remotePlaylist.toPlaylist());
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
setLoading(false);
|
||||
UIDialogs.toast(context.getString(R.string.playlist_copied_as_local_playlist));
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
withContext(Dispatchers.Main) {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private fun fetchPlaylist() {
|
||||
Logger.i(TAG, "fetchPlaylist")
|
||||
|
||||
@@ -268,43 +250,6 @@ class PlaylistFragment : MainFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDownloadState() {
|
||||
val playlist = _playlist ?: return;
|
||||
val isDownloading = StateDownloads.instance.getDownloading().any { it.groupType == VideoDownload.GROUP_PLAYLIST && it.groupID == playlist.id };
|
||||
val isDownloaded = StateDownloads.instance.isPlaylistCached(playlist.id);
|
||||
|
||||
val dp10 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, resources.displayMetrics);
|
||||
|
||||
if(isDownloaded && !isDownloading)
|
||||
_buttonDownload.setBackgroundResource(R.drawable.background_button_round_green);
|
||||
else
|
||||
_buttonDownload.setBackgroundResource(R.drawable.background_button_round);
|
||||
|
||||
if(isDownloading) {
|
||||
_buttonDownload.setImageResource(R.drawable.ic_loader_animated);
|
||||
_buttonDownload.drawable.assume<Animatable, Unit> { it.start() };
|
||||
_buttonDownload.setOnClickListener {
|
||||
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
|
||||
StateDownloads.instance.deleteCachedPlaylist(playlist.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
else if(isDownloaded) {
|
||||
_buttonDownload.setImageResource(R.drawable.ic_download_off);
|
||||
_buttonDownload.setOnClickListener {
|
||||
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
|
||||
StateDownloads.instance.deleteCachedPlaylist(playlist.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
else {
|
||||
_buttonDownload.setImageResource(R.drawable.ic_download);
|
||||
_buttonDownload.setOnClickListener {
|
||||
UISlideOverlays.showDownloadPlaylistOverlay(playlist, overlayContainer);
|
||||
}
|
||||
}
|
||||
_buttonDownload.setPadding(dp10.toInt());
|
||||
}
|
||||
|
||||
override fun canEdit(): Boolean { return _playlist != null; }
|
||||
|
||||
@@ -315,21 +260,15 @@ class PlaylistFragment : MainFragment() {
|
||||
|
||||
override fun onPlayAllClick() {
|
||||
val playlist = _playlist;
|
||||
val remotePlaylist = _remotePlaylist;
|
||||
if (playlist != null) {
|
||||
StatePlayer.instance.setPlaylist(playlist, focus = true);
|
||||
} else if (remotePlaylist != null) {
|
||||
StatePlayer.instance.setPlaylist(remotePlaylist, focus = true, shuffle = false);
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShuffleClick() {
|
||||
val playlist = _playlist;
|
||||
val remotePlaylist = _remotePlaylist;
|
||||
if (playlist != null) {
|
||||
StatePlayer.instance.setPlaylist(playlist, focus = true, shuffle = true);
|
||||
} else if (remotePlaylist != null) {
|
||||
StatePlayer.instance.setPlaylist(remotePlaylist, focus = true, shuffle = true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,19 +284,12 @@ class PlaylistFragment : MainFragment() {
|
||||
}
|
||||
override fun onVideoClicked(video: IPlatformVideo) {
|
||||
val playlist = _playlist;
|
||||
val remotePlaylist = _remotePlaylist;
|
||||
if (playlist != null) {
|
||||
val index = playlist.videos.indexOf(video);
|
||||
if (index == -1)
|
||||
return;
|
||||
|
||||
StatePlayer.instance.setPlaylist(playlist, index, true);
|
||||
} else if (remotePlaylist != null) {
|
||||
val index = remotePlaylist.contents.getResults().indexOf(video);
|
||||
if (index == -1)
|
||||
return;
|
||||
|
||||
StatePlayer.instance.setPlaylist(remotePlaylist, index, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user