Compare commits

..

107 Commits

Author SHA1 Message Date
Koen J c83a9924e2 Implemented new ApiMethods calls. 2025-03-05 17:04:48 +01:00
Koen J bbeb9b83a0 Removed dynamic Polycentric calls. 2025-03-05 11:58:09 +01:00
Kelvin 06478f3e36 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-02-27 14:34:59 +01:00
Kelvin 40f20002b2 submods 2025-02-27 14:34:51 +01:00
Koen J 442272f517 SettingsActivity can now be landscape. 2025-02-27 10:38:03 +01:00
Kelvin 88dae8e9c4 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-02-26 21:29:40 +01:00
Kelvin 1bbfa7d39e WIP home filtering 2025-02-26 21:29:06 +01:00
Koen J edc2b3d295 Fixed issue where video reload would reset video timestamp. 2025-02-25 15:32:30 +01:00
Koen J 0006da7385 Implemented sync display names. 2025-02-25 11:00:54 +01:00
Kai DeLorenzo b5ac8b3ec6 Edit Authentication.md 2025-02-20 21:59:29 +00:00
Kai 78f5169880 add recommendations assignment in video details class
Changelog: added
2025-02-20 14:43:13 -06:00
Kelvin 3361b77aec Remove accidental always update 2025-02-13 21:00:02 +01:00
Kelvin 8b7c9df286 Add to queue button on recommendations, no toast on add to watch later if dup 2025-02-12 20:16:50 +01:00
Kelvin 157d5b4c36 Fix container id conflict 2025-02-12 20:03:33 +01:00
Kelvin 44c8800bec plugin disabled update check fix 2025-02-12 19:25:29 +01:00
Kelvin 2f0ba1b1f7 Setting to check disabled plugins for updates (off by default) 2025-02-12 19:17:20 +01:00
Kelvin 36c51f1a0c Refs 2025-02-12 19:06:43 +01:00
Kelvin 1dfe18aa6f Add Apple podcasts 2025-02-12 18:58:01 +01:00
Kelvin b9bbfb44c5 Update submodules, fix apple podcast dir 2025-02-12 18:53:30 +01:00
Kelvin 83843f192d Show total downloaded content duration, Indicator how many subscriptions, save queue as playlist 2025-02-12 18:43:15 +01:00
Kelvin 8839d9f1c6 Fix for misisng exports for export playlist 2025-02-12 16:31:30 +01:00
Kelvin 0630ec1d46 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-02-11 20:31:33 +01:00
Kelvin 4dce8d6a80 Export playlist support 2025-02-11 20:31:26 +01:00
Koen J 3b62f999bf Fixed HttpFileHandler bug causing casting local webm not to work. 2025-02-11 17:41:25 +01:00
Kelvin 65ae8610fd Hide download for live videos 2025-02-11 17:06:57 +01:00
Kelvin c1c2000c98 Download container fixes 2025-02-11 16:13:07 +01:00
Kelvin 287c2d82a1 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-02-11 14:13:55 +01:00
Kelvin 5cde1650f4 DashRaw streammetadata fetching 2025-02-11 14:13:48 +01:00
Kelvin a4b90f14ab Merge branch 'hls-audio-only-download' into 'master'
Hide audio only option when no audio sources

See merge request videostreaming/grayjay!87
2025-02-11 12:30:53 +00:00
Koen J 4826b40136 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-02-11 10:32:24 +01:00
Koen J 62618224da Casting server did not bind to an automatically selected port. 2025-02-11 10:32:11 +01:00
Kai 49f15e1637 don't show audio only download option if there aren't any audio sources available
for HLS and DASH the HLS and DASH pickers give the option to only download audio

Changelog: changed
2025-02-10 22:32:56 -06:00
Kelvin e36047c890 Merge branch 'prevent-exception-replay' into 'master'
Prevent Exception Replay When Unsubscribing From Deleted Channel

See merge request videostreaming/grayjay!77
2025-02-10 19:15:09 +00:00
Kelvin 8f1199bd08 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-02-10 20:12:50 +01:00
Kelvin d6e045ea4e JSDOM, optional packages, Channel not crash if opened without plugin, downloads ordering fixes/naming 2025-02-10 20:12:43 +01:00
Kelvin 304e48996b Merge branch 'rotation-lock-fix' into 'master'
Fix Rotation Lock Activity Resume Issue

See merge request videostreaming/grayjay!83
2025-02-10 18:55:19 +00:00
Kelvin f350dc83b8 Merge branch 'brightness-fix' into 'master'
Restore Overlay Brightness When Re-entering Full Screen

See merge request videostreaming/grayjay!84
2025-02-10 18:53:38 +00:00
Kelvin ebb7beda8c Merge branch 'fix-background-playback' into 'master'
Background playback support for HLS and DASH

See merge request videostreaming/grayjay!80
2025-02-10 18:48:05 +00:00
Kelvin a01f3da66e Merge branch 'adaptive-streaming-auto-ui' into 'master'
Auto mode UI for adaptive streams (HLS and DASH)

See merge request videostreaming/grayjay!76
2025-02-10 18:47:50 +00:00
Kelvin 72f5b5fbc0 Ref 2025-02-07 19:08:38 +01:00
Kelvin 330aa495c8 Playlist dup prevention, download search and ordering, optional package support 2025-02-06 21:36:33 +01:00
Kelvin 0b529ae94d Plugin changelog support, Hide hidden from search setting, No author change warning if missing pubkey, toast on add to playlist, better autoplay description, Playlist total duration label 2025-02-06 19:19:29 +01:00
Kelvin 83b35183d0 Channel shorts tab, Forced batch parallelization, Playlist download support for live sources, hardware codec query 2025-02-05 19:40:28 +01:00
Kelvin 2cd01eb1fe Merge 2025-02-03 21:38:18 +01:00
Kelvin 07378f665a Fix http memory leak for byte responses, refs 2025-02-03 21:36:41 +01:00
Kai DeLorenzo bfd5f24f4c Fix https://github.com/futo-org/grayjay-android/issues/727 2025-01-27 21:51:17 +00:00
Kai 3d617187af update rotation lock approach
Changelog: changed
2025-01-27 12:03:25 -06:00
Koen J d040b93ca9 Updated submodules and fixed IPv6 casting play address being wrong. 2025-01-23 14:26:08 +01:00
Koen J a410e2962a Only take one line for signing. 2025-01-20 14:04:55 +01:00
Koen J f5aa8f37bb Updated youtube. 2025-01-17 22:19:02 +01:00
Koen J 7e932df450 Updated submodules. 2025-01-17 22:01:26 +01:00
Koen J 3d4741727e Updated submodules. 2025-01-17 21:37:28 +01:00
Kelvin a03b63ef74 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-01-17 21:27:23 +01:00
Kelvin 15ce3e9f20 Timeout support 2025-01-17 21:27:12 +01:00
Kai da58b72f9d add background playback support for videos without an explicit audio source
Changelog: changed
2025-01-14 11:36:37 -06:00
Kai DeLorenzo 1639bd7af1 Merge branch 'improve-full-screen-portrait-docs' into 'master'
improve full screen portrait docs

See merge request videostreaming/grayjay!79
2025-01-14 16:00:51 +00:00
Kai d474121f85 improve full screen portrait docs
Changelog: changed
2025-01-14 09:59:17 -06:00
Kai 978f76ffb6 Added current quality to auto item
Changelog: added
2025-01-10 14:38:54 -06:00
Kai 084bac00f5 Clear feed loading exceptions to prevent replay of exceptions
Changelog: changed
2025-01-08 21:54:03 -06:00
Kai 94454172dd Add UI to show when adaptive streams (HLS and DASH) are in auto mode
Changelog: added
2025-01-08 20:43:38 -06:00
Koen J 891d3cf966 Added debug message. 2025-01-06 16:55:02 +01:00
Koen J 561d5ec7ab Fixes to sync. 2025-01-06 15:56:31 +01:00
Koen J 7ce437d50a Updated submodule. 2025-01-06 11:42:14 +01:00
Kelvin 4b02d4ce90 Merge branch 'zvonimir-dev' into 'master'
workflow: Remove labeler

See merge request videostreaming/grayjay!74
2024-12-23 20:44:25 +00:00
Zvonimir Zrakić 3107185869 workflow: Remove labeler 2024-12-23 21:31:06 +01:00
Koen 2e3584a353 Merge branch 'zvonimir-dev' into 'master'
Improve issue templates

See merge request videostreaming/grayjay!73
2024-12-23 18:36:32 +00:00
Zvonimir Zrakić e5b1be195c fix: Fix issue templates, add new plugins 2024-12-23 18:45:35 +01:00
Zvonimir Zrakić dde30c9d76 Add VPN option and fix label typo 2024-12-23 18:45:18 +01:00
Koen 3830e65de8 Update bug_report.yml 2024-12-23 15:43:40 +00:00
Koen c589cf167e Merge branch 'zvonimir-dev' into 'master'
Update issue templates

See merge request videostreaming/grayjay!72
2024-12-21 22:05:42 +00:00
Zvonimir Zrakić 2fde367c82 Update issue templates 2024-12-21 22:05:42 +00:00
Kai DeLorenzo 8fd188268e Merge branch 'disable-download' into 'master'
disable download for Widevine sources

See merge request videostreaming/grayjay!70
2024-12-21 17:27:15 +00:00
Kai b65257df42 disable download for Widevine sources 2024-12-21 11:26:10 -06:00
Kelvin aaa2d7f08d Prevent crashes on non-existing assets 2024-12-19 22:00:56 +01:00
Kelvin f73e25ece6 Fix crash activity update support 2024-12-19 21:54:32 +01:00
Kelvin 78d427f208 Remove broken ref for now 2024-12-19 21:14:07 +01:00
Kelvin eaeaf3538f Better messaging on failed to connect sync 2024-12-18 22:20:11 +01:00
Kai DeLorenzo 85e381a85e Merge branch 'update-deps' into 'master'
update deps

See merge request videostreaming/grayjay!69
2024-12-18 20:37:07 +00:00
Kai 1b7ee8231b update deps 2024-12-18 14:36:40 -06:00
Kai DeLorenzo 1b8b8f5738 Merge branch 'tablet-rotation-issue' into 'master'
more recent landscape and rotation issues

See merge request videostreaming/grayjay!66
2024-12-14 20:42:11 +00:00
Kai 53df19b477 fixes for:
weird tablet issues on some screen sizes
horizontal maximized player on phones
always allow full screen rotation not working
2024-12-14 14:41:28 -06:00
Kai DeLorenzo ccf21b7580 Merge branch 'rotation-regression' into 'master'
fix rotation regression

See merge request videostreaming/grayjay!65
2024-12-14 03:13:21 +00:00
Kai 4189d62a57 fix rotation regression 2024-12-13 20:47:21 -06:00
Koen 9a3e3af614 Merge branch 'feat/apple-podcasts-plugin' into 'master'
Add Apple Podcasts plugin

See merge request videostreaming/grayjay!63
2024-12-13 18:55:16 +00:00
Stefan f7187400dc Add Apple Podcasts plugin 2024-12-13 17:59:56 +00:00
Kai DeLorenzo f55a7f0a7b Merge branch 'detect-system-setting-changes' into 'master'
detect system auto rotate setting changes

See merge request videostreaming/grayjay!62
2024-12-13 17:46:00 +00:00
Kai d6d35a645e detect system auto rotate setting changes
correctly handle lock button when full screen
2024-12-13 11:44:57 -06:00
Kai e719dcc7f5 detect system auto rotate setting changes
correctly handle lock button when full screen
2024-12-13 11:23:40 -06:00
Kai DeLorenzo bc5bc5450c Merge branch 'change-default-auto-rotate-setting' into 'master'
change default to force auto rotate while full screen

See merge request videostreaming/grayjay!61
2024-12-13 16:22:00 +00:00
Kai f4bade0c2e change default to force auto rotate while full screen 2024-12-13 10:21:40 -06:00
Koen J 9be59c674d Updated YouTube. 2024-12-13 17:13:42 +01:00
Kai DeLorenzo a1dec23c20 Merge branch 'default-reset-auto-rotate-disabled' into 'master'
remove assumptions about rotation preference

See merge request videostreaming/grayjay!60
2024-12-13 16:01:07 +00:00
Kai ed926c4e37 remove assumptions about rotation preference 2024-12-13 10:00:43 -06:00
Kai DeLorenzo ab360ed6f6 Merge branch 'force-leave-landscape-tweak' into 'master'
Force leave landscape tweak

See merge request videostreaming/grayjay!59
2024-12-13 15:43:33 +00:00
Kai 569ba3d651 suppress warning 2024-12-13 09:43:08 -06:00
Kai 60fe28c2fe simplified rotation logic 2024-12-13 09:41:52 -06:00
Kai DeLorenzo 2787e29a07 Merge branch 'delay-tweak' into 'master'
increase delay to prevent erroneous rotations

See merge request videostreaming/grayjay!58
2024-12-13 05:02:23 +00:00
Kai c77a4d08d6 increase delay to prevent erroneous rotations 2024-12-12 23:01:28 -06:00
Kai DeLorenzo 9b3f90f922 Merge branch 'auto-rotate-fixes' into 'master'
more rotation/orientation fixes

See merge request videostreaming/grayjay!57
2024-12-12 21:27:20 +00:00
Kai c88d457021 fix device getting stuck landscape or in portrait when entering and exiting full screen via the button
fix weird rotation behavior when locking and unlocking rotation via the player button
2024-12-12 15:25:02 -06:00
Koen J b20b625820 Fixed compiler errors. 2024-12-12 16:02:37 +01:00
Koen J fd95311920 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-12-12 14:13:34 +01:00
Koen J 6da5c11731 Fixed concurrent modification crash in ServiceRecordAggregator and link clicking in scroll. 2024-12-12 14:13:24 +01:00
Koen 4e58231308 Merge branch 'revamp-rotation-settings' into 'master'
Update and simplify rotation settings

See merge request videostreaming/grayjay!56
2024-12-12 12:56:21 +00:00
Kai ef0ecf249a update rotation settings 2024-12-11 14:38:58 -06:00
Kelvin 4981617f7a Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-12-11 20:24:17 +01:00
Kelvin 2070bc7007 Refs 2024-12-11 20:24:09 +01:00
176 changed files with 1848 additions and 1697 deletions
+29 -15
View File
@@ -1,19 +1,19 @@
name: Bug Report name: Bug Report
description: Let us know about an unexpected error, a crash, or an incorrect behavior. description: Let us know about an unexpected error, a crash, or an incorrect behavior.
labels: ["bug", "new"] labels: ["Bug"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
# Thank you for taking the time to fill out this bug report. # 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 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) For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
## Filing a bug report ## Filing a bug report
To fix your issues faster, we need clear reproduction cases - ideally allowing us to make it happen locally. 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. * 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 * if you've found out a particular series of UI interactions can introduce buggy behavior, please label those steps 1-n with markdown
@@ -41,18 +41,21 @@ body:
label: What plugins are you seeing the problem on? label: What plugins are you seeing the problem on?
multiple: true multiple: true
options: options:
- All - "All"
- Youtube - "Youtube"
- BiliBili (CN) - "Odysee"
- Twitch - "Rumble"
- Odysee - "Kick"
- Rumble - "Twitch"
- Kick - "PeerTube"
- PeerTube - "Patreon"
- Patreon - "Nebula"
- Nebula - "BiliBili (CN)"
- SoundCloud - "Bitchute"
- Other - "SoundCloud"
- "Dailymotion"
- "Apple Podcasts"
- "Other"
validations: validations:
required: true required: true
@@ -72,6 +75,17 @@ body:
- label: While logged out - label: While logged out
- label: N/A - label: N/A
- type: dropdown
id: vpn
attributes:
label: Are you using a VPN?
multiple: false
options:
- "No"
- "Yes"
validations:
required: true
- type: textarea - type: textarea
id: logs id: logs
attributes: attributes:
@@ -1,13 +1,13 @@
name: Documentation Issue name: Documentation Issue
description: Report an issue or suggest a change in the documentation. description: Report an issue or suggest a change in the documentation.
labels: ["documentation", "new"] labels: ["Documentation"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
# Thank you for opening a documentation change request. # 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. Use the `Documentation` issue type to report problems with the documentation in our code repositories, inside the application, or on [https://grayjay.app](https://grayjay.app) The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application. Use the `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, so report Grayjay bugs or feature requests with the `Bug report` or `Feature Request` issue types instead to get engineering attention. Technical writers monitor this issue type, so 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) For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
+2 -4
View File
@@ -1,6 +1,6 @@
name: Feature Request name: Feature Request
description: Suggest a new feature or other enhancement. description: Suggest a new feature or other enhancement.
labels: ["enhancement", "new"] labels: ["Enhancement"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
@@ -9,8 +9,6 @@ body:
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application 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 closed 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) For discussion related to enhancements, please see: [The FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
- type: textarea - type: textarea
@@ -55,4 +53,4 @@ body:
attributes: attributes:
value: | 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. **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.
-34
View File
@@ -1,34 +0,0 @@
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 }}
+6
View File
@@ -82,3 +82,9 @@
[submodule "app/src/stable/assets/sources/dailymotion"] [submodule "app/src/stable/assets/sources/dailymotion"]
path = app/src/stable/assets/sources/dailymotion path = app/src/stable/assets/sources/dailymotion
url = ../plugins/dailymotion.git url = ../plugins/dailymotion.git
[submodule "app/src/stable/assets/sources/apple-podcast"]
path = app/src/stable/assets/sources/apple-podcasts
url = ../plugins/apple-podcasts.git
[submodule "app/src/unstable/assets/sources/apple-podcasts"]
path = app/src/unstable/assets/sources/apple-podcasts
url = ../plugins/apple-podcasts.git
+1 -1
View File
@@ -197,7 +197,7 @@ dependencies {
implementation 'org.jsoup:jsoup:1.15.3' implementation 'org.jsoup:jsoup:1.15.3'
implementation 'com.google.android.flexbox:flexbox:3.0.0' implementation 'com.google.android.flexbox:flexbox:3.0.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'com.arthenica:ffmpeg-kit-full:5.1' implementation 'com.arthenica:ffmpeg-kit-full:6.0-2.LTS'
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0' implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
implementation 'com.github.dhaval2404:imagepicker:2.1' implementation 'com.github.dhaval2404:imagepicker:2.1'
implementation 'com.google.zxing:core:3.4.1' implementation 'com.google.zxing:core:3.4.1'
-1
View File
@@ -156,7 +156,6 @@
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity <activity
android:name=".activities.SettingsActivity" android:name=".activities.SettingsActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity <activity
android:name=".activities.DeveloperActivity" android:name=".activities.DeveloperActivity"
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -11,7 +11,8 @@ let Type = {
Streams: "STREAMS", Streams: "STREAMS",
Mixed: "MIXED", Mixed: "MIXED",
Live: "LIVE", Live: "LIVE",
Subscriptions: "SUBSCRIPTIONS" Subscriptions: "SUBSCRIPTIONS",
Shorts: "SHORTS"
}, },
Order: { Order: {
Chronological: "CHRONOLOGICAL" Chronological: "CHRONOLOGICAL"
@@ -244,6 +245,7 @@ class PlatformVideo extends PlatformContent {
this.viewCount = obj.viewCount ?? -1; //Long this.viewCount = obj.viewCount ?? -1; //Long
this.isLive = obj.isLive ?? false; //Boolean this.isLive = obj.isLive ?? false; //Boolean
this.isShort = !!obj.isShort ?? false;
} }
} }
class PlatformVideoDetails extends PlatformVideo { class PlatformVideoDetails extends PlatformVideo {
@@ -260,6 +262,11 @@ class PlatformVideoDetails extends PlatformVideo {
this.rating = obj.rating ?? null; //IRating this.rating = obj.rating ?? null; //IRating
this.subtitles = obj.subtitles ?? []; this.subtitles = obj.subtitles ?? [];
this.isShort = !!obj.isShort ?? false;
if (obj.getContentRecommendations) {
this.getContentRecommendations = obj.getContentRecommendations
}
} }
} }
@@ -226,6 +226,25 @@ fun Long.toHumanTime(isMs: Boolean): String {
else else
return "${prefix}${minsStr}:${secsStr}" return "${prefix}${minsStr}:${secsStr}"
} }
fun Long.toHumanDuration(isMs: Boolean): String {
var scaler = 1;
if(isMs)
scaler = 1000;
val v = Math.abs(this);
val hours = Math.max(v/(secondsInHour*scaler), 0);
val mins = Math.max((v % (secondsInHour*scaler)) / (secondsInMinute * scaler), 0);
val minsStr = mins.toString();
val seconds = Math.max(((v % (secondsInHour*scaler)) % (secondsInMinute * scaler))/scaler, 0);
val secsStr = seconds.toString().padStart(2, '0');
val prefix = if (this < 0) { "-" } else { "" };
return listOf(
if(hours > 0) "${hours}h" else null,
if(mins > 0) "${mins}m" else null ,
if(seconds > 0) "${seconds}s" else null
).filterNotNull().joinToString(" ");
}
//TODO: Determine if below stuff should have its own proper class, seems a bit too complex for a utility method //TODO: Determine if below stuff should have its own proper class, seems a bit too complex for a utility method
fun String.fixHtmlWhitespace(): Spanned { fun String.fixHtmlWhitespace(): Spanned {
@@ -6,6 +6,7 @@ import java.io.ByteArrayOutputStream
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.net.Inet4Address import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress import java.net.InetAddress
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket
@@ -1,13 +1,13 @@
package com.futo.platformplayer package com.futo.platformplayer
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.AnnouncementType import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.StateAnnouncement import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.polycentric.core.ProcessHandle import com.futo.polycentric.core.ProcessHandle
import com.futo.polycentric.core.Store import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.base64UrlToByteArray
import userpackage.Protocol import userpackage.Protocol
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.min import kotlin.math.min
@@ -40,33 +40,25 @@ fun Protocol.ImageBundle?.selectHighestResolutionImage(): Protocol.ImageManifest
return imageManifestsList.filter { it.byteCount < maximumFileSize }.maxByOrNull { abs(it.width * it.height) } return imageManifestsList.filter { it.byteCount < maximumFileSize }.maxByOrNull { abs(it.width * it.height) }
} }
fun String.getDataLinkFromUrl(): Protocol.URLInfoDataLink? {
val urlData = if (this.startsWith("polycentric://")) {
this.substring("polycentric://".length)
} else this;
val urlBytes = urlData.base64UrlToByteArray();
val urlInfo = Protocol.URLInfo.parseFrom(urlBytes);
if (urlInfo.urlType != 4L) {
return null
}
val dataLink = Protocol.URLInfoDataLink.parseFrom(urlInfo.body);
return dataLink
}
fun Protocol.Claim.resolveChannelUrl(): String? { fun Protocol.Claim.resolveChannelUrl(): String? {
return StatePlatform.instance.resolveChannelUrlByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) }) return StatePlatform.instance.resolveChannelUrlByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
} }
fun Protocol.Claim.resolveChannelUrls(): List<String> { fun Protocol.Claim.resolveChannelUrls(): List<String> {
return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) }) return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
}
suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() {
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(system))
if (!systemState.servers.contains(PolycentricCache.SERVER)) {
Logger.w("Backfill", "Polycentric prod server not added, adding it.")
addServer(PolycentricCache.SERVER)
}
val exceptions = fullyBackfillServers()
for (pair in exceptions) {
val server = pair.key
val exception = pair.value
StateAnnouncement.instance.registerAnnouncement(
"backfill-failed",
"Backfill failed",
"Failed to backfill server $server. $exception",
AnnouncementType.SESSION_RECURRING
);
Logger.e("Backfill", "Failed to backfill server $server.", exception)
}
} }
@@ -33,10 +33,10 @@ fun Boolean?.toYesNo(): String {
fun InetAddress?.toUrlAddress(): String { fun InetAddress?.toUrlAddress(): String {
return when (this) { return when (this) {
is Inet6Address -> { is Inet6Address -> {
"[${toString()}]" "[${hostAddress}]"
} }
is Inet4Address -> { is Inet4Address -> {
toString() hostAddress
} }
else -> { else -> {
throw Exception("Invalid address type") throw Exception("Invalid address type")
@@ -254,6 +254,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6) @FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = true; var progressBar: Boolean = true;
@FormField(R.string.hide_hidden_from_search, FieldForm.TOGGLE, R.string.hide_hidden_from_search_description, 7)
var hidefromSearch: Boolean = false;
fun getSearchFeedStyle(): FeedStyle { fun getSearchFeedStyle(): FeedStyle {
if(searchFeedStyle == 0) if(searchFeedStyle == 0)
@@ -412,15 +415,13 @@ class Settings : FragmentedStorageFileJson() {
var preferredPreviewQuality: Int = 5; var preferredPreviewQuality: Int = 5;
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality); fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
@FormField(R.string.simplify_sources, FieldForm.TOGGLE, R.string.simplify_sources_description, 4) @FormField(R.string.simplify_sources, FieldForm.TOGGLE, R.string.simplify_sources_description, 4)
var simplifySources: Boolean = true; var simplifySources: Boolean = true;
@FormField(R.string.auto_rotate, FieldForm.DROPDOWN, -1, 5) @FormField(R.string.always_allow_reverse_landscape_auto_rotate, FieldForm.TOGGLE, R.string.always_allow_reverse_landscape_auto_rotate_description, 5)
@DropdownFieldOptionsId(R.array.system_enabled_disabled_array) var alwaysAllowReverseLandscapeAutoRotate: Boolean = true
var autoRotate: Int = 2;
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 7) @FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 6)
@DropdownFieldOptionsId(R.array.player_background_behavior) @DropdownFieldOptionsId(R.array.player_background_behavior)
var backgroundPlay: Int = 2; var backgroundPlay: Int = 2;
@@ -643,6 +644,9 @@ class Settings : FragmentedStorageFileJson() {
@Serializable @Serializable
class Plugins { class Plugins {
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
var checkDisabledPluginsForUpdates: Boolean = false;
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0) @FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
var clearCookiesOnLogout: Boolean = true; var clearCookiesOnLogout: Boolean = true;
@@ -863,11 +867,13 @@ class Settings : FragmentedStorageFileJson() {
class Other { class Other {
@FormField(R.string.playlist_delete_confirmation, FieldForm.TOGGLE, R.string.playlist_delete_confirmation_description, 2) @FormField(R.string.playlist_delete_confirmation, FieldForm.TOGGLE, R.string.playlist_delete_confirmation_description, 2)
var playlistDeleteConfirmation: Boolean = true; var playlistDeleteConfirmation: Boolean = true;
@FormField(R.string.playlist_allow_dups, FieldForm.TOGGLE, R.string.playlist_allow_dups_description, 3)
var playlistAllowDups: Boolean = true;
@FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 3) @FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 4)
var polycentricEnabled: Boolean = true; var polycentricEnabled: Boolean = true;
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 4) @FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 5)
var polycentricLocalCache: Boolean = true; var polycentricLocalCache: Boolean = true;
} }
@@ -1,6 +1,7 @@
package com.futo.platformplayer package com.futo.platformplayer
import android.content.Context import android.content.Context
import android.content.Intent
import android.webkit.CookieManager import android.webkit.CookieManager
import androidx.work.Data import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
@@ -368,8 +368,8 @@ class UIDialogs {
} }
} }
fun showChangelogDialog(context: Context, lastVersion: Int) { fun showChangelogDialog(context: Context, lastVersion: Int, changelogs: Map<Int, String>? = null) {
val dialog = ChangelogDialog(context); val dialog = ChangelogDialog(context, changelogs);
registerDialogOpened(dialog); registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) }; dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show(); dialog.show();
@@ -79,6 +79,36 @@ class UISlideOverlays {
return menu; return menu;
} }
fun showQueueOptionsOverlay(context: Context, container: ViewGroup) {
UISlideOverlays.showOverlay(container, "Queue options", null, {
}, SlideUpMenuItem(context, R.drawable.ic_playlist, "Save as playlist", "", "Creates a new playlist with queue as videos", null, {
val nameInput = SlideUpMenuTextInput(container.context, container.context.getString(R.string.name));
val addPlaylistOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.create_new_playlist), container.context.getString(R.string.ok), false, nameInput);
addPlaylistOverlay.onOK.subscribe {
val text = nameInput.text.trim()
if (text.isBlank()) {
return@subscribe;
}
addPlaylistOverlay.hide();
nameInput.deactivate();
nameInput.clear();
StatePlayer.instance.saveQueueAsPlaylist(text);
UIDialogs.appToast("Playlist [${text}] created");
};
addPlaylistOverlay.onCancel.subscribe {
nameInput.deactivate();
nameInput.clear();
};
addPlaylistOverlay.show();
nameInput.activate();
}, false));
}
fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup): SlideUpMenuOverlay { fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup): SlideUpMenuOverlay {
val items = arrayListOf<View>(); val items = arrayListOf<View>();
@@ -335,7 +365,9 @@ class UISlideOverlays {
call = { call = {
selectedVideoVariant = it selectedVideoVariant = it
slideUpMenuOverlay.selectOption(videoButtons, it) slideUpMenuOverlay.selectOption(videoButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) if (audioButtons.isEmpty()){
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}
}, },
invokeParent = false invokeParent = false
)) ))
@@ -417,7 +449,7 @@ class UISlideOverlays {
} }
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources, items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
listOf(listOf(SlideUpMenuItem( listOf((if (audioSources != null) listOf(SlideUpMenuItem(
container.context, container.context,
R.drawable.ic_movie, R.drawable.ic_movie,
container.context.getString(R.string.none), container.context.getString(R.string.none),
@@ -430,7 +462,7 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download)); menu?.setOk(container.context.getString(R.string.download));
}, },
invokeParent = false invokeParent = false
)) + )) else listOf()) +
videoSources videoSources
.filter { it.isDownloadable() } .filter { it.isDownloadable() }
.map { .map {
@@ -895,7 +927,8 @@ class UISlideOverlays {
"${lastUpdated.videos.size} " + container.context.getString(R.string.videos), "${lastUpdated.videos.size} " + container.context.getString(R.string.videos),
tag = "", tag = "",
call = { call = {
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video); if(StatePlaylists.instance.addToPlaylist(lastUpdated.id, video))
UIDialogs.appToast("Added to playlist [${lastUpdated?.name}]", false);
StateDownloads.instance.checkForOutdatedPlaylists(); StateDownloads.instance.checkForOutdatedPlaylists();
})) }))
); );
@@ -906,7 +939,7 @@ class UISlideOverlays {
val watchLater = StatePlaylists.instance.getWatchLater(); val watchLater = StatePlaylists.instance.getWatchLater();
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions", items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
(listOf( (listOf(
if(!isLimited) if(!isLimited && !video.isLive)
SlideUpMenuItem( SlideUpMenuItem(
container.context, container.context,
R.drawable.ic_download, R.drawable.ic_download,
@@ -991,7 +1024,8 @@ class UISlideOverlays {
"${playlist.videos.size} " + container.context.getString(R.string.videos), "${playlist.videos.size} " + container.context.getString(R.string.videos),
tag = "", tag = "",
call = { call = {
StatePlaylists.instance.addToPlaylist(playlist.id, video); if(StatePlaylists.instance.addToPlaylist(playlist.id, video))
UIDialogs.appToast("Added to playlist [${playlist.name}]", false);
StateDownloads.instance.checkForOutdatedPlaylists(); StateDownloads.instance.checkForOutdatedPlaylists();
})); }));
} }
@@ -1018,7 +1052,8 @@ class UISlideOverlays {
"${lastUpdated.videos.size} " + container.context.getString(R.string.videos), "${lastUpdated.videos.size} " + container.context.getString(R.string.videos),
tag = "", tag = "",
call = { call = {
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video); if(StatePlaylists.instance.addToPlaylist(lastUpdated.id, video))
UIDialogs.appToast("Added to playlist [${lastUpdated?.name}]", false);
StateDownloads.instance.checkForOutdatedPlaylists(); StateDownloads.instance.checkForOutdatedPlaylists();
})) }))
); );
@@ -1040,7 +1075,10 @@ class UISlideOverlays {
StatePlayer.TYPE_WATCHLATER, StatePlayer.TYPE_WATCHLATER,
"${watchLater.size} " + container.context.getString(R.string.videos), "${watchLater.size} " + container.context.getString(R.string.videos),
tag = "watch later", tag = "watch later",
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }), call = {
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true))
UIDialogs.appToast("Added to watch later", false);
}),
) )
); );
@@ -1067,7 +1105,8 @@ class UISlideOverlays {
"${playlist.videos.size} " + container.context.getString(R.string.videos), "${playlist.videos.size} " + container.context.getString(R.string.videos),
tag = "", tag = "",
call = { call = {
StatePlaylists.instance.addToPlaylist(playlist.id, video); if(StatePlaylists.instance.addToPlaylist(playlist.id, video))
UIDialogs.appToast("Added to playlist [${playlist.name}]", false);
StateDownloads.instance.checkForOutdatedPlaylists(); StateDownloads.instance.checkForOutdatedPlaylists();
})); }));
} }
@@ -1281,7 +1281,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (toast.long) if (toast.long)
delay(5000); delay(5000);
else else
delay(3000); delay(2500);
} }
Logger.i(TAG, "Ending appToast loop"); Logger.i(TAG, "Ending appToast loop");
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
@@ -11,16 +11,16 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.polycentric.PolycentricStorage import com.futo.platformplayer.polycentric.PolycentricStorage
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.LoaderView import com.futo.platformplayer.views.LoaderView
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ProcessHandle import com.futo.polycentric.core.ProcessHandle
import com.futo.polycentric.core.Store import com.futo.polycentric.core.Store
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -87,7 +87,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
Logger.e(TAG, "Failed to save process secret to secret storage.", e) Logger.e(TAG, "Failed to save process secret to secret storage.", e)
} }
processHandle.addServer(PolycentricCache.SERVER); processHandle.addServer(ApiMethods.SERVER);
processHandle.setUsername(username); processHandle.setUsername(username);
StatePolycentric.instance.setProcessHandle(processHandle); StatePolycentric.instance.setProcessHandle(processHandle);
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -12,12 +12,12 @@ import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.polycentric.PolycentricStorage import com.futo.platformplayer.polycentric.PolycentricStorage
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.overlays.LoaderOverlay import com.futo.platformplayer.views.overlays.LoaderOverlay
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.KeyPair import com.futo.polycentric.core.KeyPair
import com.futo.polycentric.core.Process import com.futo.polycentric.core.Process
import com.futo.polycentric.core.ProcessSecret import com.futo.polycentric.core.ProcessSecret
@@ -145,7 +145,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
} }
StatePolycentric.instance.setProcessHandle(processHandle); StatePolycentric.instance.setProcessHandle(processHandle);
processHandle.fullyBackfillClient(PolycentricCache.SERVER); processHandle.fullyBackfillClient(ApiMethods.SERVER);
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java)); startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
finish(); finish();
@@ -21,10 +21,8 @@ import com.bumptech.glide.Glide
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.polycentric.PolycentricStorage import com.futo.platformplayer.polycentric.PolycentricStorage
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
@@ -32,8 +30,10 @@ import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.views.overlays.LoaderOverlay import com.futo.platformplayer.views.overlays.LoaderOverlay
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.Store import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
import com.futo.polycentric.core.toBase64Url import com.futo.polycentric.core.toBase64Url
import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.futo.polycentric.core.toURLInfoSystemLinkUrl
@@ -145,7 +145,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
processHandle.fullyBackfillClient(PolycentricCache.SERVER) processHandle.fullyBackfillClient(ApiMethods.SERVER)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
updateUI(); updateUI();
@@ -101,7 +101,8 @@ class SyncHomeActivity : AppCompatActivity() {
private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView { private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView {
val connected = session?.connected ?: false val connected = session?.connected ?: false
syncDeviceView.setLinkType(if (connected) LinkType.Local else LinkType.None) syncDeviceView.setLinkType(if (connected) LinkType.Local else LinkType.None)
.setName(publicKey) .setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey)
//TODO: also display public key?
.setStatus(if (connected) "Connected" else "Disconnected") .setStatus(if (connected) "Connected" else "Disconnected")
return syncDeviceView return syncDeviceView
} }
@@ -122,7 +122,11 @@ class SyncPairActivity : AppCompatActivity() {
} catch (e: Throwable) { } catch (e: Throwable) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_layoutPairingError.visibility = View.VISIBLE _layoutPairingError.visibility = View.VISIBLE
_textError.text = e.message if(e.message == "Failed to connect") {
_textError.text = "Failed to connect.\n\nThis may be due to not being on the same network, due to firewall, or vpn.\nSync currently operates only over local direct connections."
}
else
_textError.text = e.message
_layoutPairing.visibility = View.GONE _layoutPairing.visibility = View.GONE
Logger.e(TAG, "Failed to pair", e) Logger.e(TAG, "Failed to pair", e)
} }
@@ -5,6 +5,8 @@ import com.futo.platformplayer.SettingsDev
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.ensureNotMainThread import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.stores.FragmentedStorage
import okhttp3.Call import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -63,7 +65,7 @@ open class ManagedHttpClient {
constructor(builder: OkHttpClient.Builder = OkHttpClient.Builder()) { constructor(builder: OkHttpClient.Builder = OkHttpClient.Builder()) {
_builderTemplate = builder; _builderTemplate = builder;
if(SettingsDev.instance.developerMode && SettingsDev.instance.networking.allowAllCertificates) if(FragmentedStorage.isInitialized && StateApp.instance.isMainActive && SettingsDev.instance.developerMode && SettingsDev.instance.networking.allowAllCertificates)
trustAllCertificates(builder); trustAllCertificates(builder);
client = builder.addNetworkInterceptor { chain -> client = builder.addNetworkInterceptor { chain ->
val request = beforeRequest(chain.request()); val request = beforeRequest(chain.request());
@@ -73,7 +73,7 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
Logger.v(TAG, "Sent bytes $current-${current + bytesToSend}, totalBytesSent=$totalBytesSent") Logger.v(TAG, "Sent bytes $current-${current + bytesToSend}, totalBytesSent=$totalBytesSent")
current += bytesToSend.toLong() current += bytesToSend.toLong()
if (current >= end) { if (current > end) {
Logger.i(TAG, "Expected amount of bytes sent") Logger.i(TAG, "Expected amount of bytes sent")
break break
} }
@@ -30,6 +30,7 @@ class ResultCapabilities(
const val TYPE_POSTS = "POSTS"; const val TYPE_POSTS = "POSTS";
const val TYPE_MIXED = "MIXED"; const val TYPE_MIXED = "MIXED";
const val TYPE_SUBSCRIPTIONS = "SUBSCRIPTIONS"; const val TYPE_SUBSCRIPTIONS = "SUBSCRIPTIONS";
const val TYPE_SHORTS = "SHORTS";
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL"; const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";
@@ -33,13 +33,13 @@ class LocalAudioSource : IAudioSource, IStreamMetaDataSource {
} }
companion object { companion object {
fun fromSource(source: IAudioSource, path: String, fileSize: Long): LocalAudioSource { fun fromSource(source: IAudioSource, path: String, fileSize: Long, overrideContainer: String? = null): LocalAudioSource {
return LocalAudioSource( return LocalAudioSource(
source.name, source.name,
path, path,
fileSize, fileSize,
source.bitrate, source.bitrate,
source.container, overrideContainer ?: source.container,
source.codec, source.codec,
source.language source.language
); );
@@ -35,7 +35,7 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
} }
companion object { companion object {
fun fromSource(source: IVideoSource, path: String, fileSize: Long): LocalVideoSource { fun fromSource(source: IVideoSource, path: String, fileSize: Long, overrideContainer: String? = null): LocalVideoSource {
return LocalVideoSource( return LocalVideoSource(
source.name, source.name,
path, path,
@@ -43,7 +43,7 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
source.width, source.width,
source.height, source.height,
source.duration, source.duration,
source.container, overrideContainer ?: source.container,
source.codec, source.codec,
source.bitrate?:0 source.bitrate?:0
); );
@@ -13,4 +13,6 @@ interface IPlatformVideo : IPlatformContent {
val viewCount: Long; val viewCount: Long;
val isLive : Boolean; val isLive : Boolean;
val isShort: Boolean;
} }
@@ -25,6 +25,7 @@ open class SerializedPlatformVideo(
override val duration: Long, override val duration: Long,
override val viewCount: Long, override val viewCount: Long,
override val isShort: Boolean = false
) : IPlatformVideo, SerializedPlatformContent { ) : IPlatformVideo, SerializedPlatformContent {
override val contentType: ContentType = ContentType.MEDIA; override val contentType: ContentType = ContentType.MEDIA;
@@ -38,7 +38,8 @@ open class SerializedPlatformVideoDetails(
override val video: ISerializedVideoSourceDescriptor, override val video: ISerializedVideoSourceDescriptor,
override val preview: ISerializedVideoSourceDescriptor?, override val preview: ISerializedVideoSourceDescriptor?,
override val subtitles: List<SubtitleRawSource> = listOf() override val subtitles: List<SubtitleRawSource> = listOf(),
override val isShort: Boolean = false
) : IPlatformVideo, IPlatformVideoDetails { ) : IPlatformVideo, IPlatformVideoDetails {
final override val contentType: ContentType get() = ContentType.MEDIA; final override val contentType: ContentType get() = ContentType.MEDIA;
@@ -33,6 +33,7 @@ class SourcePluginConfig(
override val allowEval: Boolean = false, override val allowEval: Boolean = false,
override val allowUrls: List<String> = listOf(), override val allowUrls: List<String> = listOf(),
override val packages: List<String> = listOf(), override val packages: List<String> = listOf(),
override val packagesOptional: List<String> = listOf(),
val settings: List<Setting> = listOf(), val settings: List<Setting> = listOf(),
@@ -52,6 +53,7 @@ class SourcePluginConfig(
var allowAllHttpHeaderAccess: Boolean = false, var allowAllHttpHeaderAccess: Boolean = false,
var maxDownloadParallelism: Int = 0, var maxDownloadParallelism: Int = 0,
var reduceFunctionsInLimitedVersion: Boolean = false, var reduceFunctionsInLimitedVersion: Boolean = false,
var changelog: HashMap<String, List<String>>? = null
) : IV8PluginConfig { ) : IV8PluginConfig {
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl); val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
@@ -101,6 +103,10 @@ class SourcePluginConfig(
if(!packages.contains(pack)) if(!packages.contains(pack))
return false; return false;
} }
for(pack in newConfig.packagesOptional) {
if(!packagesOptional.contains(pack))
return false;
}
//Developer Submit Url should be same or empty //Developer Submit Url should be same or empty
if(!newConfig.developerSubmitUrl.isNullOrEmpty() && developerSubmitUrl != newConfig.developerSubmitUrl) if(!newConfig.developerSubmitUrl.isNullOrEmpty() && developerSubmitUrl != newConfig.developerSubmitUrl)
return false; return false;
@@ -129,7 +135,7 @@ class SourcePluginConfig(
val currentlyInstalledPlugin = StatePlugins.instance.getPlugin(id); val currentlyInstalledPlugin = StatePlugins.instance.getPlugin(id);
if (currentlyInstalledPlugin != null) { if (currentlyInstalledPlugin != null) {
if (currentlyInstalledPlugin.config.scriptPublicKey != scriptPublicKey) { if (currentlyInstalledPlugin.config.scriptPublicKey != scriptPublicKey && !currentlyInstalledPlugin.config.scriptPublicKey.isNullOrEmpty()) {
list.add(Pair( list.add(Pair(
"Different Author", "Different Author",
"This plugin was signed by a different author. Please ensure that this is correct and that the plugin was not provided by a malicious actor.")); "This plugin was signed by a different author. Please ensure that this is correct and that the plugin was not provided by a malicious actor."));
@@ -178,6 +184,19 @@ class SourcePluginConfig(
return _allowUrlsLower.any { it == host || (it.length > 0 && it[0] == '.' && host.matchesDomain(it)) }; return _allowUrlsLower.any { it == host || (it.length > 0 && it[0] == '.' && host.matchesDomain(it)) };
} }
fun getChangelogString(version: String): String?{
if(changelog == null || !changelog!!.containsKey(version))
return null;
val changelog = changelog!![version]!!;
if(changelog.size > 1) {
return "Changelog (${version})\n" + changelog.map { " - " + it.trim() }.joinToString("\n");
}
else if(changelog.size == 1) {
return "Changelog (${version})\n" + changelog[0].trim();
}
return null;
}
companion object { companion object {
fun fromJson(json: String, sourceUrl: String? = null): SourcePluginConfig { fun fromJson(json: String, sourceUrl: String? = null): SourcePluginConfig {
val obj = Serializer.json.decodeFromString<SourcePluginConfig>(json); val obj = Serializer.json.decodeFromString<SourcePluginConfig>(json);
@@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
open class JSVideo : JSContent, IPlatformVideo, IPluginSourced { open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
@@ -17,6 +18,7 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
final override val viewCount: Long; final override val viewCount: Long;
final override val isLive: Boolean; final override val isLive: Boolean;
final override val isShort: Boolean;
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformVideo"; val contextName = "PlatformVideo";
@@ -26,5 +28,6 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
duration = _content.getOrThrow<Int>(config, "duration", contextName).toLong(); duration = _content.getOrThrow<Int>(config, "duration", contextName).toLong();
viewCount = _content.getOrThrow(config, "viewCount", contextName); viewCount = _content.getOrThrow(config, "viewCount", contextName);
isLive = _content.getOrThrow(config, "isLive", contextName); isLive = _content.getOrThrow(config, "isLive", contextName);
isShort = _content.getOrDefault(config, "isShort", contextName, false) ?: false;
} }
} }
@@ -4,6 +4,8 @@ import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource
import com.futo.platformplayer.api.media.models.streams.sources.other.StreamMetaData
import com.futo.platformplayer.api.media.platforms.js.DevJSClient 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.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
@@ -14,8 +16,8 @@ import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.others.Language import com.futo.platformplayer.others.Language
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawSource { class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawSource, IStreamMetaDataSource {
override val container : String = "application/dash+xml"; override val container : String;
override val name : String; override val name : String;
override val codec: String; override val codec: String;
override val bitrate: Int; override val bitrate: Int;
@@ -29,11 +31,14 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
override val hasGenerate: Boolean; override val hasGenerate: Boolean;
override var streamMetaData: StreamMetaData? = null;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) { constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) {
val contextName = "DashRawSource"; val contextName = "DashRawSource";
val config = plugin.config; val config = plugin.config;
name = _obj.getOrThrow(config, "name", contextName); name = _obj.getOrThrow(config, "name", contextName);
url = _obj.getOrThrow(config, "url", contextName); url = _obj.getOrThrow(config, "url", contextName);
container = _obj.getOrDefault<String>(config, "container", contextName, null) ?: "application/dash+xml";
manifest = _obj.getOrThrow(config, "manifest", contextName); manifest = _obj.getOrThrow(config, "manifest", contextName);
codec = _obj.getOrDefault(config, "codec", contextName, "") ?: ""; codec = _obj.getOrDefault(config, "codec", contextName, "") ?: "";
bitrate = _obj.getOrDefault(config, "bitrate", contextName, 0) ?: 0; bitrate = _obj.getOrDefault(config, "bitrate", contextName, 0) ?: 0;
@@ -50,15 +55,28 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
throw IllegalStateException("Source object already closed"); throw IllegalStateException("Source object already closed");
val plugin = _plugin.getUnderlyingPlugin(); val plugin = _plugin.getUnderlyingPlugin();
var result: String? = null;
if(_plugin is DevJSClient) if(_plugin is DevJSClient)
return StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRaw", false) { result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRaw", false) {
_plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") { _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
_obj.invokeString("generate"); _obj.invokeString("generate");
} }
} }
else else
return _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") { result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
_obj.invokeString("generate"); _obj.invokeString("generate");
} }
if(result != null){
val initStart = _obj.getOrDefault<Int>(_config, "initStart", "JSDashManifestRawSource", null) ?: 0;
val initEnd = _obj.getOrDefault<Int>(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0;
val indexStart = _obj.getOrDefault<Int>(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0;
val indexEnd = _obj.getOrDefault<Int>(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0;
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
}
}
return result;
} }
} }
@@ -6,6 +6,8 @@ import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource
import com.futo.platformplayer.api.media.models.streams.sources.other.StreamMetaData
import com.futo.platformplayer.api.media.platforms.js.DevJSClient 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.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
@@ -20,8 +22,8 @@ interface IJSDashManifestRawSource {
var manifest: String?; var manifest: String?;
fun generate(): String?; fun generate(): String?;
} }
open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSource { open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSource, IStreamMetaDataSource {
override val container : String = "application/dash+xml"; override val container : String;
override val name : String; override val name : String;
override val width: Int; override val width: Int;
override val height: Int; override val height: Int;
@@ -36,11 +38,14 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
override val hasGenerate: Boolean; override val hasGenerate: Boolean;
val canMerge: Boolean; val canMerge: Boolean;
override var streamMetaData: StreamMetaData? = null;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) { constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) {
val contextName = "DashRawSource"; val contextName = "DashRawSource";
val config = plugin.config; val config = plugin.config;
name = _obj.getOrThrow(config, "name", contextName); name = _obj.getOrThrow(config, "name", contextName);
url = _obj.getOrThrow(config, "url", contextName); url = _obj.getOrThrow(config, "url", contextName);
container = _obj.getOrDefault<String>(config, "container", contextName, null) ?: "application/dash+xml";
manifest = _obj.getOrDefault<String>(config, "manifest", contextName, null); manifest = _obj.getOrDefault<String>(config, "manifest", contextName, null);
width = _obj.getOrDefault(config, "width", contextName, 0) ?: 0; width = _obj.getOrDefault(config, "width", contextName, 0) ?: 0;
height = _obj.getOrDefault(config, "height", contextName, 0) ?: 0; height = _obj.getOrDefault(config, "height", contextName, 0) ?: 0;
@@ -57,17 +62,30 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
return manifest; return manifest;
if(_obj.isClosed) if(_obj.isClosed)
throw IllegalStateException("Source object already closed"); throw IllegalStateException("Source object already closed");
var result: String? = null;
if(_plugin is DevJSClient) { if(_plugin is DevJSClient) {
return StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRawSource.generate()") { result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRawSource.generate()") {
_plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", { _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
_obj.invokeString("generate"); _obj.invokeString("generate");
}); });
} }
} }
else else
return _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", { result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
_obj.invokeString("generate"); _obj.invokeString("generate");
}); });
if(result != null){
val initStart = _obj.getOrDefault<Int>(_config, "initStart", "JSDashManifestRawSource", null) ?: 0;
val initEnd = _obj.getOrDefault<Int>(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0;
val indexStart = _obj.getOrDefault<Int>(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0;
val indexEnd = _obj.getOrDefault<Int>(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0;
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
}
}
return result;
} }
} }
@@ -100,12 +118,16 @@ class JSDashManifestMergingRawSource(
if(videoDash == null) return null; if(videoDash == null) return null;
//TODO: Temporary simple solution..make more reliable version //TODO: Temporary simple solution..make more reliable version
var result: String? = null;
val audioAdaptationSet = adaptationSetRegex.find(audioDash!!); val audioAdaptationSet = adaptationSetRegex.find(audioDash!!);
if(audioAdaptationSet != null) { if(audioAdaptationSet != null) {
return videoDash.replace("</AdaptationSet>", "</AdaptationSet>\n" + audioAdaptationSet.value) result = videoDash.replace("</AdaptationSet>", "</AdaptationSet>\n" + audioAdaptationSet.value)
} }
else else
return videoDash; result = videoDash;
return result;
} }
companion object { companion object {
@@ -64,7 +64,7 @@ class StateCasting {
private val _scopeMain = CoroutineScope(Dispatchers.Main); private val _scopeMain = CoroutineScope(Dispatchers.Main);
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get(); private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
private val _castServer = ManagedHttpServer(9999); private val _castServer = ManagedHttpServer();
private var _started = false; private var _started = false;
var devices: HashMap<String, CastingDevice> = hashMapOf(); var devices: HashMap<String, CastingDevice> = hashMapOf();
@@ -1,37 +1,24 @@
package com.futo.platformplayer.dialogs package com.futo.platformplayer.dialogs
import android.app.AlertDialog import android.app.AlertDialog
import android.app.PendingIntent.*
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.os.Bundle import android.os.Bundle
import android.text.method.ScrollingMovementMethod import android.text.method.ScrollingMovementMethod
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.WindowManager
import android.widget.Button import android.widget.Button
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.receivers.InstallReceiver
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateUpdate import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.*
import java.io.File
import java.io.InputStream
class ChangelogDialog(context: Context?) : AlertDialog(context) { class ChangelogDialog(context: Context?, val changelogs: Map<Int, String>? = null) : AlertDialog(context) {
companion object { companion object {
private val TAG = "ChangelogDialog"; private val TAG = "ChangelogDialog";
} }
@@ -48,7 +35,11 @@ class ChangelogDialog(context: Context?) : AlertDialog(context) {
private var _maxVersion: Int = 0; private var _maxVersion: Int = 0;
private var _managedHttpClient = ManagedHttpClient(); private var _managedHttpClient = ManagedHttpClient();
private val _taskDownloadChangelog = TaskHandler<Int, String?>(StateApp.instance.scopeGetter, { version -> StateUpdate.instance.downloadChangelog(_managedHttpClient, version) }) private val _taskDownloadChangelog = TaskHandler<Int, String?>(StateApp.instance.scopeGetter, { version -> if(changelogs == null)
StateUpdate.instance.downloadChangelog(_managedHttpClient, version)
else
changelogs[version]
})
.success { setChangelog(it); } .success { setChangelog(it); }
.exception<Throwable> { .exception<Throwable> {
Logger.w(TAG, "Failed to load changelog.", it); Logger.w(TAG, "Failed to load changelog.", it);
@@ -97,7 +88,7 @@ class ChangelogDialog(context: Context?) : AlertDialog(context) {
setVersion(version); setVersion(version);
val currentVersion = BuildConfig.VERSION_CODE; val currentVersion = BuildConfig.VERSION_CODE;
_buttonUpdate.visibility = if (currentVersion == _maxVersion) View.GONE else View.VISIBLE; _buttonUpdate.visibility = if (currentVersion == _maxVersion || changelogs != null) View.GONE else View.VISIBLE;
} }
private fun setVersion(version: Int) { private fun setVersion(version: Int) {
@@ -22,7 +22,6 @@ import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComm
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
@@ -30,6 +29,7 @@ import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.ClaimType import com.futo.polycentric.core.ClaimType
import com.futo.polycentric.core.Store import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
@@ -54,6 +54,7 @@ class PluginUpdateDialog : AlertDialog {
private lateinit var _buttonInstall: LinearLayout; private lateinit var _buttonInstall: LinearLayout;
private lateinit var _textPlugin: TextView; private lateinit var _textPlugin: TextView;
private lateinit var _textChangelog: TextView;
private lateinit var _textProgres: TextView; private lateinit var _textProgres: TextView;
private lateinit var _textError: TextView; private lateinit var _textError: TextView;
private lateinit var _textResult: TextView; private lateinit var _textResult: TextView;
@@ -94,6 +95,7 @@ class PluginUpdateDialog : AlertDialog {
_buttonInstall = findViewById(R.id.button_install); _buttonInstall = findViewById(R.id.button_install);
_textPlugin = findViewById(R.id.text_plugin); _textPlugin = findViewById(R.id.text_plugin);
_textChangelog = findViewById(R.id.text_changelog);
_textProgres = findViewById(R.id.text_progress); _textProgres = findViewById(R.id.text_progress);
_textError = findViewById(R.id.text_error); _textError = findViewById(R.id.text_error);
_textResult = findViewById(R.id.text_result); _textResult = findViewById(R.id.text_result);
@@ -110,6 +112,27 @@ class PluginUpdateDialog : AlertDialog {
_updateSpinner = findViewById(R.id.update_spinner); _updateSpinner = findViewById(R.id.update_spinner);
_iconPlugin = findViewById(R.id.icon_plugin); _iconPlugin = findViewById(R.id.icon_plugin);
try {
var changelogVersion = _newConfig.version.toString();
if (_newConfig.changelog != null && _newConfig.changelog?.containsKey(changelogVersion) == true) {
_textChangelog.movementMethod = ScrollingMovementMethod();
val changelog = _newConfig.changelog!![changelogVersion]!!;
if(changelog.size > 1) {
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog.map { " - " + it.trim() }.joinToString("\n");
}
else if(changelog.size == 1) {
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog[0].trim();
}
else
_textChangelog.visibility = View.GONE;
} else
_textChangelog.visibility = View.GONE;
}
catch(ex: Throwable) {
_textChangelog.visibility = View.GONE;
Logger.e(TAG, "Invalid changelog? ", ex);
}
_buttonCancel1.setOnClickListener { _buttonCancel1.setOnClickListener {
dismiss(); dismiss();
}; };
@@ -141,11 +141,17 @@ class VideoDownload {
var error: String? = null; var error: String? = null;
var videoFilePath: String? = null; var videoFilePath: String? = null;
var videoFileName: String? = null; var videoFileNameBase: String? = null;
var videoFileNameExt: String? = null;
val videoFileName: String? get() = if(videoFileNameBase.isNullOrEmpty()) null else videoFileNameBase + (if(!videoFileNameExt.isNullOrEmpty()) "." + videoFileNameExt else "");
var videoOverrideContainer: String? = null;
var videoFileSize: Long? = null; var videoFileSize: Long? = null;
var audioFilePath: String? = null; var audioFilePath: String? = null;
var audioFileName: String? = null; var audioFileNameBase: String? = null;
var audioFileNameExt: String? = null;
val audioFileName: String? get() = if(audioFileNameBase.isNullOrEmpty()) null else audioFileNameBase + (if(!audioFileNameExt.isNullOrEmpty()) "." + audioFileNameExt else "");
var audioOverrideContainer: String? = null;
var audioFileSize: Long? = null; var audioFileSize: Long? = null;
var subtitleFilePath: String? = null; var subtitleFilePath: String? = null;
@@ -235,11 +241,13 @@ class VideoDownload {
videoDetails = null; videoDetails = null;
videoSource = null; videoSource = null;
videoSourceLive = null; videoSourceLive = null;
videoOverrideContainer = null;
} }
if(requiresLiveAudioSource && !isLiveAudioSourceValid) { if(requiresLiveAudioSource && !isLiveAudioSourceValid) {
videoDetails = null; videoDetails = null;
audioSource = null; audioSource = null;
videoSourceLive = null; videoSourceLive = null;
audioOverrideContainer = null;
} }
if(video == null && videoDetails == null) if(video == null && videoDetails == null)
throw IllegalStateException("Missing information for download to complete"); throw IllegalStateException("Missing information for download to complete");
@@ -310,6 +318,10 @@ class VideoDownload {
if(vsource == null) if(vsource == null)
vsource = VideoHelper.selectBestVideoSource(videoSources, targetPixelCount!!.toInt(), arrayOf()) vsource = VideoHelper.selectBestVideoSource(videoSources, targetPixelCount!!.toInt(), arrayOf())
// ?: throw IllegalStateException("Could not find a valid video source for video"); // ?: throw IllegalStateException("Could not find a valid video source for video");
if(vsource is JSSource) {
this.hasVideoRequestExecutor = this.hasVideoRequestExecutor || vsource.hasRequestExecutor;
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (vsource is JSDashManifestRawSource && vsource.hasGenerate);
}
if(vsource == null) { if(vsource == null) {
videoSource = null; videoSource = null;
@@ -361,6 +373,12 @@ class VideoDownload {
asource = VideoHelper.selectBestAudioSource(audioSources, arrayOf(), null, targetBitrate) asource = VideoHelper.selectBestAudioSource(audioSources, arrayOf(), null, targetBitrate)
?: if(videoSource != null ) null ?: if(videoSource != null ) null
else throw DownloadException("Could not find a valid video or audio source for download") else throw DownloadException("Could not find a valid video or audio source for download")
if(asource is JSSource) {
this.hasAudioRequestExecutor = this.hasAudioRequestExecutor || asource.hasRequestExecutor;
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (asource is JSDashManifestRawSource && asource.hasGenerate);
}
if(asource == null) { if(asource == null) {
audioSource = null; audioSource = null;
if(!original.video.isUnMuxed || original.video.videoSources.size == 0) if(!original.video.isUnMuxed || original.video.videoSources.size == 0)
@@ -400,11 +418,13 @@ class VideoDownload {
else audioSource; else audioSource;
if(actualVideoSource != null) { if(actualVideoSource != null) {
videoFileName = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}].${videoContainerToExtension(actualVideoSource!!.container)}".sanitizeFileName(); videoFileNameBase = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}]".sanitizeFileName();
videoFileNameExt = videoContainerToExtension(actualVideoSource!!.container);
videoFilePath = File(downloadDir, videoFileName!!).absolutePath; videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
} }
if(actualAudioSource != null) { if(actualAudioSource != null) {
audioFileName = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}].${audioContainerToExtension(actualAudioSource!!.container)}".sanitizeFileName(); audioFileNameBase = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}]".sanitizeFileName();
audioFileNameExt = audioContainerToExtension(actualAudioSource!!.container);
audioFilePath = File(downloadDir, audioFileName!!).absolutePath; audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
} }
if(subtitleSource != null) { if(subtitleSource != null) {
@@ -1052,8 +1072,8 @@ class VideoDownload {
fun complete() { fun complete() {
Logger.i(TAG, "VideoDownload Complete [${name}]"); Logger.i(TAG, "VideoDownload Complete [${name}]");
val existing = StateDownloads.instance.getCachedVideo(id); val existing = StateDownloads.instance.getCachedVideo(id);
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0) }; val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0, videoOverrideContainer) };
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSourceToUse!!, it, audioFileSize ?: 0) }; val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) };
val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) }; val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) };
if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource) if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource)
@@ -1082,7 +1102,7 @@ class VideoDownload {
StateDownloads.instance.updateCachedVideo(existing); StateDownloads.instance.updateCachedVideo(existing);
} }
else { else {
val newVideo = VideoLocal(videoDetails!!); val newVideo = VideoLocal(videoDetails!!, OffsetDateTime.now());
if(localVideoSource != null) if(localVideoSource != null)
newVideo.videoSource.add(localVideoSource); newVideo.videoSource.add(localVideoSource);
if(localAudioSource != null) if(localAudioSource != null)
@@ -1134,7 +1154,7 @@ class VideoDownload {
else if (container.contains("video/x-matroska")) else if (container.contains("video/x-matroska"))
return "mkv"; return "mkv";
else else
return "video"; return "video";//throw IllegalStateException("Unknown container: " + container)
} }
fun audioContainerToExtension(container: String): String { fun audioContainerToExtension(container: String): String {
@@ -1145,11 +1165,11 @@ class VideoDownload {
else if (container.contains("audio/mp3")) else if (container.contains("audio/mp3"))
return "mp3"; return "mp3";
else if (container.contains("audio/webm")) else if (container.contains("audio/webm"))
return "webma"; return "webm";
else if (container == "application/vnd.apple.mpegurl") else if (container == "application/vnd.apple.mpegurl")
return "mp4"; return "mp4a";
else else
return "audio"; return "audio";// throw IllegalStateException("Unknown container: " + container)
} }
fun subtitleContainerToExtension(container: String?): String { fun subtitleContainerToExtension(container: String?): String {
@@ -39,7 +39,7 @@ class VideoExport {
this.subtitleSource = subtitleSource; this.subtitleSource = subtitleSource;
} }
suspend fun export(context: Context, onProgress: ((Double) -> Unit)? = null): DocumentFile = coroutineScope { suspend fun export(context: Context, onProgress: ((Double) -> Unit)? = null, documentRoot: DocumentFile? = null): DocumentFile = coroutineScope {
val v = videoSource; val v = videoSource;
val a = audioSource; val a = audioSource;
val s = subtitleSource; val s = subtitleSource;
@@ -50,7 +50,7 @@ class VideoExport {
if (s != null) sourceCount++; if (s != null) sourceCount++;
val outputFile: DocumentFile?; val outputFile: DocumentFile?;
val downloadRoot = StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set"); val downloadRoot = documentRoot ?: StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set");
if (sourceCount > 1) { if (sourceCount > 1) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container); val outputFileName = videoLocal.name.sanitizeFileName(true) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container);
val f = downloadRoot.createFile("video/mp4", outputFileName) val f = downloadRoot.createFile("video/mp4", outputFileName)
@@ -23,6 +23,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.stores.v2.IStoreItem import com.futo.platformplayer.stores.v2.IStoreItem
import java.io.File import java.io.File
import java.time.OffsetDateTime import java.time.OffsetDateTime
@@ -70,14 +71,21 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem {
override val isLive: Boolean get() = videoSerialized.isLive; override val isLive: Boolean get() = videoSerialized.isLive;
override val isShort: Boolean get() = videoSerialized.isShort;
//TODO: Offline subtitles //TODO: Offline subtitles
override val subtitles: List<ISubtitleSource> = listOf(); override val subtitles: List<ISubtitleSource> = listOf();
constructor(video: SerializedPlatformVideoDetails) { @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
var downloadDate: OffsetDateTime? = null;
constructor(video: SerializedPlatformVideoDetails, downloadDate: OffsetDateTime? = null) {
this.videoSerialized = video; this.videoSerialized = video;
this.downloadDate = downloadDate;
} }
constructor(video: IPlatformVideoDetails, subtitleSources: List<SubtitleRawSource>) { constructor(video: IPlatformVideoDetails, subtitleSources: List<SubtitleRawSource>) {
this.videoSerialized = SerializedPlatformVideoDetails.fromVideo(video, subtitleSources); this.videoSerialized = SerializedPlatformVideoDetails.fromVideo(video, subtitleSources);
downloadDate = OffsetDateTime.now();
} }
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null; override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;
@@ -32,6 +32,7 @@ import com.futo.platformplayer.engine.internal.V8Converter
import com.futo.platformplayer.engine.packages.PackageBridge import com.futo.platformplayer.engine.packages.PackageBridge
import com.futo.platformplayer.engine.packages.PackageDOMParser import com.futo.platformplayer.engine.packages.PackageDOMParser
import com.futo.platformplayer.engine.packages.PackageHttp import com.futo.platformplayer.engine.packages.PackageHttp
import com.futo.platformplayer.engine.packages.PackageJSDOM
import com.futo.platformplayer.engine.packages.PackageUtilities import com.futo.platformplayer.engine.packages.PackageUtilities
import com.futo.platformplayer.engine.packages.V8Package import com.futo.platformplayer.engine.packages.V8Package
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
@@ -94,7 +95,11 @@ class V8Plugin {
withDependency(PackageBridge(this, config)); withDependency(PackageBridge(this, config));
for(pack in config.packages) for(pack in config.packages)
withDependency(getPackage(pack)); withDependency(getPackage(pack)!!);
for(pack in config.packagesOptional)
getPackage(pack, true)?.let {
withDependency(it);
}
} }
fun changeAllowDevSubmit(isAllowed: Boolean) { fun changeAllowDevSubmit(isAllowed: Boolean) {
@@ -254,13 +259,14 @@ class V8Plugin {
} }
} }
private fun getPackage(packageName: String): V8Package { private fun getPackage(packageName: String, allowNull: Boolean = false): V8Package? {
//TODO: Auto get all package types? //TODO: Auto get all package types?
return when(packageName) { return when(packageName) {
"DOMParser" -> PackageDOMParser(this) "DOMParser" -> PackageDOMParser(this)
"Http" -> PackageHttp(this, config) "Http" -> PackageHttp(this, config)
"Utilities" -> PackageUtilities(this, config) "Utilities" -> PackageUtilities(this, config)
else -> throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}"); "JSDOM" -> PackageJSDOM(this, config)
else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}");
}; };
} }
@@ -5,6 +5,7 @@ interface IV8PluginConfig {
val allowEval: Boolean; val allowEval: Boolean;
val allowUrls: List<String>; val allowUrls: List<String>;
val packages: List<String>; val packages: List<String>;
val packagesOptional: List<String>;
} }
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
@@ -13,17 +14,20 @@ class V8PluginConfig : IV8PluginConfig {
override val allowEval: Boolean; override val allowEval: Boolean;
override val allowUrls: List<String>; override val allowUrls: List<String>;
override val packages: List<String>; override val packages: List<String>;
override val packagesOptional: List<String>;
constructor() { constructor() {
name = "Unknown"; name = "Unknown";
allowEval = false; allowEval = false;
allowUrls = listOf(); allowUrls = listOf();
packages = listOf(); packages = listOf();
packagesOptional = listOf();
} }
constructor(name: String, allowEval: Boolean, allowUrls: List<String>, packages: List<String> = listOf()) { constructor(name: String, allowEval: Boolean, allowUrls: List<String>, packages: List<String> = listOf(), packagesOptional: List<String> = listOf()) {
this.name = name; this.name = name;
this.allowEval = allowEval; this.allowEval = allowEval;
this.allowUrls = allowUrls; this.allowUrls = allowUrls;
this.packages = packages; this.packages = packages;
this.packagesOptional = packagesOptional;
} }
} }
@@ -1,8 +1,12 @@
package com.futo.platformplayer.engine.packages package com.futo.platformplayer.engine.packages
import android.media.MediaCodec
import android.media.MediaCodecList
import com.caoccao.javet.annotations.V8Function import com.caoccao.javet.annotations.V8Function
import com.caoccao.javet.annotations.V8Property import com.caoccao.javet.annotations.V8Property
import com.caoccao.javet.utils.JavetResourceUtils
import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.reference.V8ValueFunction
import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
@@ -16,6 +20,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@@ -37,6 +42,18 @@ class PackageBridge : V8Package {
_config = config; _config = config;
_client = plugin.httpClient; _client = plugin.httpClient;
_clientAuth = plugin.httpClientAuth; _clientAuth = plugin.httpClientAuth;
withScript("""
function setTimeout(func, delay) {
let args = Array.prototype.slice.call(arguments, 2);
return bridge.setTimeout(func.bind(globalThis, ...args), delay || 0);
}
""".trimIndent());
withScript("""
function clearTimeout(id) {
bridge.clearTimeout(id);
}
""".trimIndent());
} }
@@ -62,6 +79,48 @@ class PackageBridge : V8Package {
value.close(); value.close();
} }
var timeoutCounter = 0;
var timeoutMap = HashSet<Int>();
@V8Function
fun setTimeout(func: V8ValueFunction, timeout: Long): Int {
val id = timeoutCounter++;
val funcClone = func.toClone<V8ValueFunction>()
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
delay(timeout);
synchronized(timeoutMap) {
if(!timeoutMap.contains(id)) {
JavetResourceUtils.safeClose(funcClone);
return@launch;
}
timeoutMap.remove(id);
}
try {
_plugin.whenNotBusy {
funcClone.callVoid(null, arrayOf<Any>());
}
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed timeout callback", ex);
}
finally {
JavetResourceUtils.safeClose(funcClone);
}
};
synchronized(timeoutMap) {
timeoutMap.add(id);
}
return id;
}
@V8Function
fun clearTimeout(id: Int) {
synchronized(timeoutMap) {
if(timeoutMap.contains(id))
timeoutMap.remove(id);
}
}
@V8Function @V8Function
fun toast(str: String) { fun toast(str: String) {
Logger.i(TAG, "Plugin toast [${_config.name}]: ${str}"); Logger.i(TAG, "Plugin toast [${_config.name}]: ${str}");
@@ -130,7 +189,44 @@ class PackageBridge : V8Package {
return false; return false;
} }
@V8Function
fun getHardwareCodecs(): List<String>{
return getSupportedHardwareMediaCodecs();
}
companion object { companion object {
private const val TAG = "PackageBridge"; private const val TAG = "PackageBridge";
private var _mediaCodecList: MutableList<String> = mutableListOf();
private var _mediaCodecListHardware: MutableList<String> = mutableListOf();
fun getSupportedMediaCodecs(): List<String>{
synchronized(_mediaCodecList) {
if(_mediaCodecList.size <= 0)
updateMediaCodecList();
return _mediaCodecList;
}
}
fun getSupportedHardwareMediaCodecs(): List<String>{
synchronized(_mediaCodecList) {
if(_mediaCodecList.size <= 0)
updateMediaCodecList();
return _mediaCodecListHardware;
}
}
private fun updateMediaCodecList() {
_mediaCodecList.clear();
_mediaCodecListHardware.clear();
for(codec in MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos) {
if(!codec.isEncoder) {
_mediaCodecList.add(codec.canonicalName);
if (codec.isHardwareAccelerated)
_mediaCodecListHardware.add(codec.canonicalName);
}
}
}
} }
} }
@@ -21,9 +21,13 @@ import com.futo.platformplayer.engine.internal.IV8Convertable
import com.futo.platformplayer.engine.internal.V8BindObject import com.futo.platformplayer.engine.internal.V8BindObject
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask
import kotlin.concurrent.thread
import kotlin.streams.asSequence import kotlin.streams.asSequence
class PackageHttp: V8Package { class PackageHttp: V8Package {
@@ -42,6 +46,9 @@ class PackageHttp: V8Package {
override val name: String get() = "Http"; override val name: String get() = "Http";
override val variableName: String get() = "http"; override val variableName: String get() = "http";
private var _batchPoolLock: Any = Any();
private var _batchPool: ForkJoinPool? = null;
constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) { constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) {
_config = config; _config = config;
@@ -51,6 +58,37 @@ class PackageHttp: V8Package {
_packageClientAuth = PackageHttpClient(this, _clientAuth); _packageClientAuth = PackageHttpClient(this, _clientAuth);
} }
/*
Automatically adjusting threadpool dedicated per PackageHttp for batch requests.
*/
private fun <T, R> autoParallelPool(data: List<T>, parallelism: Int, handle: (T)->R): List<Pair<R?, Throwable?>> {
synchronized(_batchPoolLock) {
val threadsToUse = if (parallelism <= 0) data.size else Math.min(parallelism, data.size);
if(_batchPool == null)
_batchPool = ForkJoinPool(threadsToUse);
var pool = _batchPool ?: return listOf();
if(pool.poolSize < threadsToUse) { //Resize pool
pool.shutdown();
_batchPool = ForkJoinPool(threadsToUse);
pool = _batchPool ?: return listOf();
}
val resultTasks = mutableListOf<ForkJoinTask<Pair<R?, Throwable?>>>();
for(item in data){
resultTasks.add(pool.submit<Pair<R?, Throwable?>> {
try {
return@submit Pair<R?, Throwable?>(handle(item), null);
}
catch(ex: Throwable) {
return@submit Pair<R?, Throwable?>(null, ex);
}
});
}
return resultTasks.map { it.join() };
}
}
@V8Function @V8Function
fun newClient(withAuth: Boolean): PackageHttpClient { fun newClient(withAuth: Boolean): PackageHttpClient {
val httpClient = if(withAuth) _clientAuth.clone() else _client.clone(); val httpClient = if(withAuth) _clientAuth.clone() else _client.clone();
@@ -176,8 +214,6 @@ class PackageHttp: V8Package {
obj.set("url", url); obj.set("url", url);
obj.set("code", code); obj.set("code", code);
if(body != null) { if(body != null) {
val buffer = runtime.createV8ValueArrayBuffer(body.size);
buffer.fromBytes(body);
obj.set("body", body); obj.set("body", body);
} }
obj.set("headers", headers); obj.set("headers", headers);
@@ -236,16 +272,19 @@ class PackageHttp: V8Package {
//Finalizer //Finalizer
@V8Function @V8Function
fun execute(): List<IBridgeHttpResponse?> { fun execute(): List<IBridgeHttpResponse?> {
return _reqs.parallelStream().map { return _package.autoParallelPool(_reqs, -1) {
if(it.second.method == "DUMMY") if(it.second.method == "DUMMY")
return@map null; return@autoParallelPool null;
if(it.second.body != null) if(it.second.body != null)
return@map it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType); return@autoParallelPool it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType);
else else
return@map it.first.request(it.second.method, it.second.url, it.second.headers, it.second.respType); return@autoParallelPool it.first.request(it.second.method, it.second.url, it.second.headers, it.second.respType);
} }.map {
.asSequence() if(it.second != null)
.toList(); throw it.second!!;
else
return@map it.first;
}.toList();
} }
} }
@@ -439,11 +478,8 @@ class PackageHttp: V8Package {
else { else {
headers?.forEach { (header, values) -> headers?.forEach { (header, values) ->
val lowerCaseHeader = header.lowercase() val lowerCaseHeader = header.lowercase()
if(lowerCaseHeader == "set-cookie") { if(lowerCaseHeader == "set-cookie" && !values.any { it.lowercase().contains("httponly") })
result[lowerCaseHeader] = values.filter{ result[lowerCaseHeader] = values;
!it.lowercase().contains("httponly")
};
}
else else
result[lowerCaseHeader] = values; result[lowerCaseHeader] = values;
} }
@@ -0,0 +1,20 @@
package com.futo.platformplayer.engine.packages
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.states.StateApp
class PackageJSDOM : V8Package {
@Transient
private val _config: IV8PluginConfig;
override val name: String get() = "JSDOM";
override val variableName: String get() = "packageJSDOM";
constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) {
_config = config;
plugin.withDependency(StateApp.instance.contextOrNull ?: return, "scripts/JSDOM.js");
}
}
@@ -13,7 +13,6 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.fixHtmlLinks import com.futo.platformplayer.fixHtmlLinks
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
@@ -21,6 +20,7 @@ import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.platform.PlatformLinkView import com.futo.platformplayer.views.platform.PlatformLinkView
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.toName import com.futo.polycentric.core.toName
import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.futo.polycentric.core.toURLInfoSystemLinkUrl
@@ -134,9 +134,7 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment {
} }
} }
if(!map.containsKey("Harbor")) if(!map.containsKey("Harbor"))
this.context?.let { map.set("Harbor", polycentricProfile.getHarborUrl());
map.set("Harbor", polycentricProfile.getHarborUrl(it));
}
if (map.isNotEmpty()) if (map.isNotEmpty())
setLinks(map, if (polycentricProfile.systemState.username.isNotBlank()) polycentricProfile.systemState.username else _lastChannel?.name ?: "") setLinks(map, if (polycentricProfile.systemState.username.isNotBlank()) polycentricProfile.systemState.username else _lastChannel?.name ?: "")
@@ -29,7 +29,6 @@ import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.fragment.mainactivity.main.FeedView import com.futo.platformplayer.fragment.mainactivity.main.FeedView
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
@@ -39,11 +38,12 @@ import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
import com.futo.polycentric.core.PolycentricProfile
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.max import kotlin.math.max
class ChannelContentsFragment : Fragment(), IChannelTabFragment { class ChannelContentsFragment(private val subType: String? = null) : Fragment(), IChannelTabFragment {
private var _recyclerResults: RecyclerView? = null; private var _recyclerResults: RecyclerView? = null;
private var _glmVideo: GridLayoutManager? = null; private var _glmVideo: GridLayoutManager? = null;
private var _loading = false; private var _loading = false;
@@ -73,9 +73,12 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
if (lastPolycentricProfile != null) if (lastPolycentricProfile != null)
pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile); pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile);
if(pager == null) if(pager == null) {
pager = StatePlatform.instance.getChannelContent(channel.url); if(subType != null)
pager = StatePlatform.instance.getChannelContent(channel.url, subType);
else
pager = StatePlatform.instance.getChannelContent(channel.url);
}
return pager; return pager;
} }
@@ -367,6 +370,6 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
companion object { companion object {
val TAG = "VideoListFragment"; val TAG = "VideoListFragment";
fun newInstance() = ChannelContentsFragment().apply { } fun newInstance(subType: String? = null) = ChannelContentsFragment(subType).apply { }
} }
} }
@@ -16,12 +16,12 @@ import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.viewholders.CreatorViewHolder import com.futo.platformplayer.views.adapters.viewholders.CreatorViewHolder
import com.futo.polycentric.core.PolycentricProfile
class ChannelListFragment : Fragment, IChannelTabFragment { class ChannelListFragment : Fragment, IChannelTabFragment {
private var _channels: ArrayList<IPlatformChannel> = arrayListOf(); private var _channels: ArrayList<IPlatformChannel> = arrayListOf();
@@ -8,8 +8,8 @@ import android.widget.TextView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.views.SupportView import com.futo.platformplayer.views.SupportView
import com.futo.polycentric.core.PolycentricProfile
class ChannelMonetizationFragment : Fragment, IChannelTabFragment { class ChannelMonetizationFragment : Fragment, IChannelTabFragment {
@@ -1,7 +1,7 @@
package com.futo.platformplayer.fragment.channel.tab package com.futo.platformplayer.fragment.channel.tab
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.polycentric.core.PolycentricProfile
interface IChannelTabFragment { interface IChannelTabFragment {
fun setChannel(channel: IPlatformChannel) fun setChannel(channel: IPlatformChannel)
@@ -90,7 +90,7 @@ class BuyFragment : MainFragment() {
try { try {
val currencies = StatePayment.instance.getAvailableCurrencies("grayjay"); val currencies = StatePayment.instance.getAvailableCurrencies("grayjay");
val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay"); val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay");
val country = StatePayment.instance.getPaymentCountryFromIP()?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } }; val country = StatePayment.instance.getPaymentCountryFromIP(true)?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } };
val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id)) } }; val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id)) } };
if(currency != null && prices.containsKey(currency.id)) { if(currency != null && prices.containsKey(currency.id)) {
@@ -25,6 +25,7 @@ import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.channels.SerializedChannel import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.ContentType
@@ -41,7 +42,6 @@ import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SearchType import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.selectHighestResolutionImage import com.futo.platformplayer.selectHighestResolutionImage
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
@@ -54,29 +54,14 @@ import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter
import com.futo.platformplayer.views.others.CreatorThumbnail import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.views.subscriptions.SubscribeButton import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.polycentric.core.OwnedClaim import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.PublicKey import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
@Serializable
data class PolycentricProfile(
val system: PublicKey, val systemState: SystemState, val ownedClaims: List<OwnedClaim>
) {
fun getHarborUrl(context: Context): String{
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(system));
val url = system.systemToURLInfoSystemLinkUrl(systemState.servers.asIterable());
return "https://harbor.social/" + url.substring("polycentric://".length);
}
}
class ChannelFragment : MainFragment() { class ChannelFragment : MainFragment() {
override val isMainView: Boolean = true override val isMainView: Boolean = true
@@ -143,15 +128,14 @@ class ChannelFragment : MainFragment() {
private val _onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {} private val _onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {}
private val _taskLoadPolycentricProfile: TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?> private val _taskLoadPolycentricProfile: TaskHandler<PlatformID, PolycentricProfile?>
private val _taskGetChannel: TaskHandler<String, IPlatformChannel> private val _taskGetChannel: TaskHandler<String, IPlatformChannel>
init { init {
inflater.inflate(R.layout.fragment_channel, this) inflater.inflate(R.layout.fragment_channel, this)
_taskLoadPolycentricProfile = _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>({ fragment.lifecycleScope },
TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>({ fragment.lifecycleScope },
{ id -> { id ->
return@TaskHandler PolycentricCache.instance.getProfileAsync(id) return@TaskHandler ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, id.claimFieldType.toLong(), id.claimType.toLong(), id.value!!)
}).success { setPolycentricProfile(it, animate = true) }.exception<Throwable> { }).success { setPolycentricProfile(it, animate = true) }.exception<Throwable> {
Logger.w(TAG, "Failed to load polycentric profile.", it) Logger.w(TAG, "Failed to load polycentric profile.", it)
} }
@@ -237,8 +221,8 @@ class ChannelFragment : MainFragment() {
} }
adapter.onAddToWatchLaterClicked.subscribe { content -> adapter.onAddToWatchLaterClicked.subscribe { content ->
if (content is IPlatformVideo) { if (content is IPlatformVideo) {
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true) if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true))
UIDialogs.toast("Added to watch later\n[${content.name}]") UIDialogs.toast("Added to watch later\n[${content.name}]")
} }
} }
adapter.onUrlClicked.subscribe { url -> adapter.onUrlClicked.subscribe { url ->
@@ -327,7 +311,7 @@ class ChannelFragment : MainFragment() {
_creatorThumbnail.setThumbnail(parameter.thumbnail, true) _creatorThumbnail.setThumbnail(parameter.thumbnail, true)
Glide.with(_imageBanner).clear(_imageBanner) Glide.with(_imageBanner).clear(_imageBanner)
loadPolycentricProfile(parameter.id, parameter.url) loadPolycentricProfile(parameter.id)
} }
_url = parameter.url _url = parameter.url
@@ -341,7 +325,7 @@ class ChannelFragment : MainFragment() {
_creatorThumbnail.setThumbnail(parameter.channel.thumbnail, true) _creatorThumbnail.setThumbnail(parameter.channel.thumbnail, true)
Glide.with(_imageBanner).clear(_imageBanner) Glide.with(_imageBanner).clear(_imageBanner)
loadPolycentricProfile(parameter.channel.id, parameter.channel.url) loadPolycentricProfile(parameter.channel.id)
} }
_url = parameter.channel.url _url = parameter.channel.url
@@ -358,16 +342,8 @@ class ChannelFragment : MainFragment() {
_tabs.selectTab(_tabs.getTabAt(selectedTabIndex)) _tabs.selectTab(_tabs.getTabAt(selectedTabIndex))
} }
private fun loadPolycentricProfile(id: PlatformID, url: String) { private fun loadPolycentricProfile(id: PlatformID) {
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(url, true) _taskLoadPolycentricProfile.run(id)
if (cachedPolycentricProfile != null) {
setPolycentricProfile(cachedPolycentricProfile, animate = true)
if (cachedPolycentricProfile.expired) {
_taskLoadPolycentricProfile.run(id)
}
} else {
_taskLoadPolycentricProfile.run(id)
}
} }
private fun setLoading(isLoading: Boolean) { private fun setLoading(isLoading: Boolean) {
@@ -457,6 +433,12 @@ class ChannelFragment : MainFragment() {
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons) _fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
} }
if(plugin != null && plugin.capabilities.hasGetChannelCapabilities) {
if(plugin.getChannelCapabilities()?.types?.contains(ResultCapabilities.TYPE_SHORTS) ?: false &&
!(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(ChannelTab.SHORTS.ordinal.toLong())) {
(_viewPager.adapter as ChannelViewPagerAdapter).insert(1, ChannelTab.SHORTS);
}
}
} }
} }
@@ -469,8 +451,13 @@ class ChannelFragment : MainFragment() {
R.string.subscribers R.string.subscribers
).lowercase() else "" ).lowercase() else ""
val supportsPlaylists = var supportsPlaylists = false;
StatePlatform.instance.getChannelClient(channel.url).capabilities.hasGetChannelPlaylists try {
supportsPlaylists = StatePlatform.instance.getChannelClient(channel.url).capabilities.hasGetChannelPlaylists
} catch (ex: Throwable) {
//Ignore error
Logger.e(TAG, "Failed to check if supports playlists", ex);
}
val playlistPosition = 1 val playlistPosition = 1
if (supportsPlaylists && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem( if (supportsPlaylists && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(
ChannelTab.PLAYLISTS.ordinal.toLong() ChannelTab.PLAYLISTS.ordinal.toLong()
@@ -521,20 +508,13 @@ class ChannelFragment : MainFragment() {
private fun setPolycentricProfileOr(url: String, or: () -> Unit) { private fun setPolycentricProfileOr(url: String, or: () -> Unit) {
setPolycentricProfile(null, animate = false) setPolycentricProfile(null, animate = false)
or()
val cachedProfile = channel?.let { PolycentricCache.instance.getCachedProfile(url) }
if (cachedProfile != null) {
setPolycentricProfile(cachedProfile, animate = false)
} else {
or()
}
} }
private fun setPolycentricProfile( private fun setPolycentricProfile(
cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean profile: PolycentricProfile?, animate: Boolean
) { ) {
val dp35 = 35.dp(resources) val dp35 = 35.dp(resources)
val profile = cachedPolycentricProfile?.profile
val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35)?.let { val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35)?.let {
it.toURLInfoSystemLinkUrl( it.toURLInfoSystemLinkUrl(
profile.system.toProto(), it.process, profile.systemState.servers.toList() profile.system.toProto(), it.process, profile.systemState.servers.toList()
@@ -23,7 +23,6 @@ import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
@@ -32,6 +31,7 @@ import com.futo.platformplayer.views.adapters.CommentWithReferenceViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.overlays.RepliesOverlay import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.polycentric.core.PublicKey import com.futo.polycentric.core.PublicKey
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.UnknownHostException import java.net.UnknownHostException
@@ -82,8 +82,8 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
}; };
adapter.onAddToWatchLaterClicked.subscribe(this) { adapter.onAddToWatchLaterClicked.subscribe(this) {
if(it is IPlatformVideo) { if(it is IPlatformVideo) {
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true); if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true))
UIDialogs.toast("Added to watch later\n[${it.name}]"); UIDialogs.toast("Added to watch later\n[${it.name}]");
} }
}; };
adapter.onLongPress.subscribe(this) { adapter.onLongPress.subscribe(this) {
@@ -17,6 +17,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.isHttpUrl import com.futo.platformplayer.isHttpUrl
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -222,6 +223,12 @@ class ContentSearchResultsFragment : MainFragment() {
setSortByOptions(null); setSortByOptions(null);
} }
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
if(Settings.instance.search.hidefromSearch)
return super.filterResults(results.filter { !StateMeta.instance.isVideoHidden(it.url) && !StateMeta.instance.isCreatorHidden(it.author.url) });
return super.filterResults(results)
}
override fun reload() { override fun reload() {
loadResults(); loadResults();
} }
@@ -10,6 +10,7 @@ import android.widget.EditText
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.Spinner import android.widget.Spinner
import android.widget.TextView
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@@ -26,6 +27,7 @@ class CreatorsFragment : MainFragment() {
private var _overlayContainer: FrameLayout? = null; private var _overlayContainer: FrameLayout? = null;
private var _containerSearch: FrameLayout? = null; private var _containerSearch: FrameLayout? = null;
private var _editSearch: EditText? = null; private var _editSearch: EditText? = null;
private var _textMeta: TextView? = null;
private var _buttonClearSearch: ImageButton? = null private var _buttonClearSearch: ImageButton? = null
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@@ -34,6 +36,7 @@ class CreatorsFragment : MainFragment() {
val editSearch: EditText = view.findViewById(R.id.edit_search); val editSearch: EditText = view.findViewById(R.id.edit_search);
val buttonClearSearch: ImageButton = view.findViewById(R.id.button_clear_search) val buttonClearSearch: ImageButton = view.findViewById(R.id.button_clear_search)
_editSearch = editSearch _editSearch = editSearch
_textMeta = view.findViewById(R.id.text_meta);
_buttonClearSearch = buttonClearSearch _buttonClearSearch = buttonClearSearch
buttonClearSearch.setOnClickListener { buttonClearSearch.setOnClickListener {
editSearch.text.clear() editSearch.text.clear()
@@ -41,7 +44,11 @@ class CreatorsFragment : MainFragment() {
_buttonClearSearch?.visibility = View.INVISIBLE; _buttonClearSearch?.visibility = View.INVISIBLE;
} }
val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription)); val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription)) { subs ->
_textMeta?.let {
it.text = "${subs.size} creator${if(subs.size > 1) "s" else ""}";
}
};
adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) }; adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) };
adapter.onSettings.subscribe { sub -> _overlayContainer?.let { UISlideOverlays.showSubscriptionOptionsOverlay(sub, it) } } adapter.onSettings.subscribe { sub -> _overlayContainer?.let { UISlideOverlays.showSubscriptionOptionsOverlay(sub, it) } }
@@ -4,8 +4,13 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.Spinner
import android.widget.TextView import android.widget.TextView
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R import com.futo.platformplayer.R
@@ -17,6 +22,7 @@ import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.toHumanBytesSize import com.futo.platformplayer.toHumanBytesSize
import com.futo.platformplayer.toHumanDuration
import com.futo.platformplayer.views.AnyInsertedAdapterView import com.futo.platformplayer.views.AnyInsertedAdapterView
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
import com.futo.platformplayer.views.adapters.viewholders.VideoDownloadViewHolder import com.futo.platformplayer.views.adapters.viewholders.VideoDownloadViewHolder
@@ -25,6 +31,7 @@ import com.futo.platformplayer.views.items.PlaylistDownloadItem
import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.others.ProgressBar
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.OffsetDateTime
class DownloadsFragment : MainFragment() { class DownloadsFragment : MainFragment() {
private val TAG = "DownloadsFragment"; private val TAG = "DownloadsFragment";
@@ -92,8 +99,12 @@ class DownloadsFragment : MainFragment() {
private val _listDownloadedHeader: LinearLayout; private val _listDownloadedHeader: LinearLayout;
private val _listDownloadedMeta: TextView; private val _listDownloadedMeta: TextView;
private val _listDownloadSearch: EditText;
private val _listDownloaded: AnyInsertedAdapterView<VideoLocal, VideoDownloadViewHolder>; private val _listDownloaded: AnyInsertedAdapterView<VideoLocal, VideoDownloadViewHolder>;
private var lastDownloads: List<VideoLocal>? = null;
private var ordering: String? = "nameAsc";
constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) { constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) {
inflater.inflate(R.layout.fragment_downloads, this); inflater.inflate(R.layout.fragment_downloads, this);
_frag = frag; _frag = frag;
@@ -104,6 +115,7 @@ class DownloadsFragment : MainFragment() {
_listActiveDownloadsContainer = findViewById(R.id.downloads_active_downloads_container); _listActiveDownloadsContainer = findViewById(R.id.downloads_active_downloads_container);
_listActiveDownloadsMeta = findViewById(R.id.downloads_active_downloads_meta); _listActiveDownloadsMeta = findViewById(R.id.downloads_active_downloads_meta);
_listDownloadSearch = findViewById(R.id.downloads_search);
_listActiveDownloads = findViewById(R.id.downloads_active_downloads_list); _listActiveDownloads = findViewById(R.id.downloads_active_downloads_list);
_listPlaylistsContainer = findViewById(R.id.downloads_playlist_container); _listPlaylistsContainer = findViewById(R.id.downloads_playlist_container);
@@ -113,6 +125,30 @@ class DownloadsFragment : MainFragment() {
_listDownloadedHeader = findViewById(R.id.downloads_videos_header); _listDownloadedHeader = findViewById(R.id.downloads_videos_header);
_listDownloadedMeta = findViewById(R.id.downloads_videos_meta); _listDownloadedMeta = findViewById(R.id.downloads_videos_meta);
_listDownloadSearch.addTextChangedListener {
updateContentFilters();
}
val spinnerSortBy: Spinner = findViewById(R.id.spinner_sortby);
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
};
spinnerSortBy.setSelection(0);
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
when(pos) {
0 -> ordering = "nameAsc"
1 -> ordering = "nameDesc"
2 -> ordering = "downloadDateAsc"
3 -> ordering = "downloadDateDesc"
4 -> ordering = "releasedAsc"
5 -> ordering = "releasedDesc"
else -> ordering = null
}
updateContentFilters()
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
};
_listDownloaded = findViewById<RecyclerView>(R.id.list_downloaded) _listDownloaded = findViewById<RecyclerView>(R.id.list_downloaded)
.asAnyWithTop(findViewById(R.id.downloads_top)) { .asAnyWithTop(findViewById(R.id.downloads_top)) {
it.onClick.subscribe { it.onClick.subscribe {
@@ -125,7 +161,6 @@ class DownloadsFragment : MainFragment() {
reloadUI(); reloadUI();
} }
fun reloadUI() { fun reloadUI() {
val usage = StateDownloads.instance.getTotalUsage(true); val usage = StateDownloads.instance.getTotalUsage(true);
_usageUsed.text = "${usage.usage.toHumanBytesSize()} " + context.getString(R.string.used); _usageUsed.text = "${usage.usage.toHumanBytesSize()} " + context.getString(R.string.used);
@@ -181,10 +216,32 @@ class DownloadsFragment : MainFragment() {
_listDownloadedHeader.visibility = GONE; _listDownloadedHeader.visibility = GONE;
} else { } else {
_listDownloadedHeader.visibility = VISIBLE; _listDownloadedHeader.visibility = VISIBLE;
_listDownloadedMeta.text = "(${downloaded.size} ${context.getString(R.string.videos).lowercase()})"; _listDownloadedMeta.text = "(${downloaded.size} ${context.getString(R.string.videos).lowercase()}${if(downloaded.size > 0) ", ${downloaded.sumOf { it.duration }.toHumanDuration(false)}" else ""})";
} }
_listDownloaded.setData(downloaded); lastDownloads = downloaded;
_listDownloaded.setData(filterDownloads(downloaded));
}
fun updateContentFilters(){
val toFilter = lastDownloads ?: return;
_listDownloaded.setData(filterDownloads(toFilter));
}
fun filterDownloads(vids: List<VideoLocal>): List<VideoLocal>{
var vidsToReturn = vids;
if(!_listDownloadSearch.text.isNullOrEmpty())
vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) };
if(!ordering.isNullOrEmpty()) {
vidsToReturn = when(ordering){
"downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX };
"downloadDateDesc" -> vidsToReturn.sortedByDescending { it.downloadDate ?: OffsetDateTime.MIN };
"nameAsc" -> vidsToReturn.sortedBy { it.name.lowercase() }
"nameDesc" -> vidsToReturn.sortedByDescending { it.name.lowercase() }
"releasedAsc" -> vidsToReturn.sortedBy { it.datetime ?: OffsetDateTime.MAX }
"releasedDesc" -> vidsToReturn.sortedByDescending { it.datetime ?: OffsetDateTime.MIN }
else -> vidsToReturn
}
}
return vidsToReturn;
} }
} }
} }
@@ -23,6 +23,7 @@ import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.NoResultsView import com.futo.platformplayer.views.NoResultsView
import com.futo.platformplayer.views.ToggleBar
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.InsertedViewHolder import com.futo.platformplayer.views.adapters.InsertedViewHolder
@@ -94,6 +95,8 @@ class HomeFragment : MainFragment() {
class HomeView : ContentFeedView<HomeFragment> { class HomeView : ContentFeedView<HomeFragment> {
override val feedStyle: FeedStyle get() = Settings.instance.home.getHomeFeedStyle(); override val feedStyle: FeedStyle get() = Settings.instance.home.getHomeFeedStyle();
private var _toggleBar: ToggleBar? = null;
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>; private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar
@@ -127,6 +130,8 @@ class HomeFragment : MainFragment() {
}, fragment); }, fragment);
}; };
initializeToolbarContent();
setPreviewsEnabled(Settings.instance.home.previewFeedItems); setPreviewsEnabled(Settings.instance.home.previewFeedItems);
showAnnouncementView() showAnnouncementView()
} }
@@ -201,13 +206,43 @@ class HomeFragment : MainFragment() {
loadResults(); loadResults();
} }
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> { private val _filterLock = Object();
return results.filter { !StateMeta.instance.isVideoHidden(it.url) && !StateMeta.instance.isCreatorHidden(it.author.url) }; private var _toggleRecent = false;
fun initializeToolbarContent() {
//Not stable enough with current viewport paging, doesn't work with less results, and reloads content instead of just re-filtering existing
/*
_toggleBar = ToggleBar(context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
}
synchronized(_filterLock) {
_toggleBar?.setToggles(
//TODO: loadResults needs to be replaced with an internal reload of the current content
ToggleBar.Toggle("Recent", _toggleRecent) { _toggleRecent = it; loadResults(false) }
)
}
_toolbarContentView.addView(_toggleBar, 0);
*/
} }
private fun loadResults() { override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
return results.filter {
if(StateMeta.instance.isVideoHidden(it.url))
return@filter false;
if(StateMeta.instance.isCreatorHidden(it.author.url))
return@filter false;
if(_toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 23) {
return@filter false;
}
return@filter true;
};
}
private fun loadResults(withRefetch: Boolean = true) {
setLoading(true); setLoading(true);
_taskGetPager.run(true); _taskGetPager.run(withRefetch);
} }
private fun loadedResult(pager : IPager<IPlatformContent>) { private fun loadedResult(pager : IPager<IPlatformContent>) {
if (pager is EmptyPager<IPlatformContent>) { if (pager is EmptyPager<IPlatformContent>) {
@@ -8,6 +8,7 @@ import android.view.ViewGroup
import androidx.core.app.ShareCompat import androidx.core.app.ShareCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@@ -78,6 +79,14 @@ class PlaylistFragment : MainFragment() {
val nameInput = SlideUpMenuTextInput(context, context.getString(R.string.name)); val nameInput = SlideUpMenuTextInput(context, context.getString(R.string.name));
val editPlaylistOverlay = SlideUpMenuOverlay(context, overlayContainer, context.getString(R.string.edit_playlist), context.getString(R.string.ok), false, nameInput); val editPlaylistOverlay = SlideUpMenuOverlay(context, overlayContainer, context.getString(R.string.edit_playlist), context.getString(R.string.ok), false, nameInput);
_buttonExport.setOnClickListener {
_playlist?.let {
val context = StateApp.instance.contextOrNull ?: return@let;
if(context is IWithResultLauncher)
StateDownloads.instance.exportPlaylist(context, it.id);
}
};
_buttonDownload.visibility = View.VISIBLE; _buttonDownload.visibility = View.VISIBLE;
editPlaylistOverlay.onOK.subscribe { editPlaylistOverlay.onOK.subscribe {
val text = nameInput.text; val text = nameInput.text;
@@ -146,7 +155,7 @@ class PlaylistFragment : MainFragment() {
setName(it.name); setName(it.name);
//TODO: Implement support for pagination //TODO: Implement support for pagination
setVideos(it.videos, false); setVideos(it.videos, false);
setVideoCount(it.videos.size); setMetadata(it.videos.size, it.videos.sumOf { it.duration });
setLoading(false); setLoading(false);
} }
.exception<Throwable> { .exception<Throwable> {
@@ -174,8 +183,9 @@ class PlaylistFragment : MainFragment() {
if (parameter != null) { if (parameter != null) {
setName(parameter.name) setName(parameter.name)
setVideos(parameter.videos, true) setVideos(parameter.videos, true)
setVideoCount(parameter.videos.size) setMetadata(parameter.videos.size, parameter.videos.sumOf { it.duration })
setButtonDownloadVisible(true) setButtonDownloadVisible(true)
setButtonExportVisible(false)
setButtonEditVisible(true) setButtonEditVisible(true)
if (!StatePlaylists.instance.playlistStore.hasItem { it.id == parameter.id }) { if (!StatePlaylists.instance.playlistStore.hasItem { it.id == parameter.id }) {
@@ -187,7 +197,7 @@ class PlaylistFragment : MainFragment() {
} else { } else {
setName(null) setName(null)
setVideos(null, false) setVideos(null, false)
setVideoCount(-1) setMetadata(-1, -1);
setButtonDownloadVisible(false) setButtonDownloadVisible(false)
setButtonEditVisible(false) setButtonEditVisible(false)
} }
@@ -195,7 +205,7 @@ class PlaylistFragment : MainFragment() {
_playlist = null _playlist = null
_url = parameter.url _url = parameter.url
setVideoCount(parameter.videoCount) setMetadata(parameter.videoCount, -1);
setName(parameter.name) setName(parameter.name)
setVideos(null, false) setVideos(null, false)
setButtonDownloadVisible(false) setButtonDownloadVisible(false)
@@ -208,7 +218,7 @@ class PlaylistFragment : MainFragment() {
setName(null) setName(null)
setVideos(null, false) setVideos(null, false)
setVideoCount(-1) setMetadata(-1, -1);
setButtonDownloadVisible(false) setButtonDownloadVisible(false)
setButtonEditVisible(false) setButtonEditVisible(false)
@@ -33,10 +33,8 @@ import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.fixHtmlWhitespace import com.futo.platformplayer.fixHtmlWhitespace
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
@@ -47,7 +45,6 @@ import com.futo.platformplayer.views.adapters.ChannelTab
import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView
import com.futo.platformplayer.views.comments.AddCommentView import com.futo.platformplayer.views.comments.AddCommentView
import com.futo.platformplayer.views.others.CreatorThumbnail import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.others.Toggle
import com.futo.platformplayer.views.overlays.RepliesOverlay import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
import com.futo.platformplayer.views.platform.PlatformIndicator import com.futo.platformplayer.views.platform.PlatformIndicator
@@ -57,6 +54,8 @@ import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Models import com.futo.polycentric.core.Models
import com.futo.polycentric.core.Opinion import com.futo.polycentric.core.Opinion
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import com.google.android.flexbox.FlexboxLayout import com.google.android.flexbox.FlexboxLayout
import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.shape.CornerFamily import com.google.android.material.shape.CornerFamily
@@ -112,7 +111,7 @@ class PostDetailFragment : MainFragment {
private var _isLoading = false; private var _isLoading = false;
private var _post: IPlatformPostDetails? = null; private var _post: IPlatformPostDetails? = null;
private var _postOverview: IPlatformPost? = null; private var _postOverview: IPlatformPost? = null;
private var _polycentricProfile: PolycentricCache.CachedPolycentricProfile? = null; private var _polycentricProfile: PolycentricProfile? = null;
private var _version = 0; private var _version = 0;
private var _isRepliesVisible: Boolean = false; private var _isRepliesVisible: Boolean = false;
private var _repliesAnimator: ViewPropertyAnimator? = null; private var _repliesAnimator: ViewPropertyAnimator? = null;
@@ -169,7 +168,7 @@ class PostDetailFragment : MainFragment {
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment); UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope }; } else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>(StateApp.instance.scopeGetter, { PolycentricCache.instance.getProfileAsync(it) }) private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) })
.success { it -> setPolycentricProfile(it, animate = true) } .success { it -> setPolycentricProfile(it, animate = true) }
.exception<Throwable> { .exception<Throwable> {
Logger.w(TAG, "Failed to load claims.", it); Logger.w(TAG, "Failed to load claims.", it);
@@ -274,7 +273,7 @@ class PostDetailFragment : MainFragment {
}; };
_buttonStore.setOnClickListener { _buttonStore.setOnClickListener {
_polycentricProfile?.profile?.systemState?.store?.let { _polycentricProfile?.systemState?.store?.let {
try { try {
val uri = Uri.parse(it); val uri = Uri.parse(it);
val intent = Intent(Intent.ACTION_VIEW); val intent = Intent(Intent.ACTION_VIEW);
@@ -334,7 +333,7 @@ class PostDetailFragment : MainFragment {
} }
try { try {
val queryReferencesResponse = ApiMethods.getQueryReferences(PolycentricCache.SERVER, ref, null,null, val queryReferencesResponse = ApiMethods.getQueryReferences(ApiMethods.SERVER, ref, null,null,
arrayListOf( arrayListOf(
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType( Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(
ContentType.OPINION.value).setValue( ContentType.OPINION.value).setValue(
@@ -604,16 +603,8 @@ class PostDetailFragment : MainFragment {
private fun fetchPolycentricProfile() { private fun fetchPolycentricProfile() {
val author = _post?.author ?: _postOverview?.author ?: return; val author = _post?.author ?: _postOverview?.author ?: return;
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(author.url, true);
if (cachedPolycentricProfile != null) {
setPolycentricProfile(cachedPolycentricProfile, animate = false);
if (cachedPolycentricProfile.expired) {
_taskLoadPolycentricProfile.run(author.id);
}
} else {
setPolycentricProfile(null, animate = false); setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(author.id); _taskLoadPolycentricProfile.run(author.id);
}
} }
private fun setChannelMeta(value: IPlatformPost?) { private fun setChannelMeta(value: IPlatformPost?) {
@@ -639,17 +630,18 @@ class PostDetailFragment : MainFragment {
_repliesOverlay.cleanup(); _repliesOverlay.cleanup();
} }
private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { private fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) {
_polycentricProfile = cachedPolycentricProfile; _polycentricProfile = polycentricProfile;
if (cachedPolycentricProfile?.profile == null) { val pp = _polycentricProfile;
if (pp == null) {
_layoutMonetization.visibility = View.GONE; _layoutMonetization.visibility = View.GONE;
_creatorThumbnail.setHarborAvailable(false, animate, null); _creatorThumbnail.setHarborAvailable(false, animate, null);
return; return;
} }
_layoutMonetization.visibility = View.VISIBLE; _layoutMonetization.visibility = View.VISIBLE;
_creatorThumbnail.setHarborAvailable(true, animate, cachedPolycentricProfile.profile.system.toProto()); _creatorThumbnail.setHarborAvailable(true, animate, pp.system.toProto());
} }
private fun fetchPost() { private fun fetchPost() {
@@ -237,7 +237,19 @@ class SourceDetailFragment : MainFragment() {
BigButtonGroup(c, context.getString(R.string.update), BigButtonGroup(c, context.getString(R.string.update),
BigButton(c, context.getString(R.string.check_for_updates), context.getString(R.string.checks_for_new_versions_of_the_source), R.drawable.ic_update) { BigButton(c, context.getString(R.string.check_for_updates), context.getString(R.string.checks_for_new_versions_of_the_source), R.drawable.ic_update) {
checkForUpdatesSource(); checkForUpdatesSource();
} },
if(config.changelog?.any() == true)
BigButton(c, context.getString(R.string.changelog), context.getString(R.string.changelog_plugin_description), R.drawable.ic_list) {
UIDialogs.showChangelogDialog(context, config.version, config.changelog!!.filterKeys { it.toIntOrNull() != null }
.mapKeys { it.key.toInt() }
.mapValues { config.getChangelogString(it.key.toString()) ?: "" });
}.apply {
this.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
};
}
else
null
) )
); );
@@ -204,8 +204,10 @@ class SubscriptionsFeedFragment : MainFragment() {
val feed = StateSubscriptions.instance.getFeed(group?.id); val feed = StateSubscriptions.instance.getFeed(group?.id);
val currentExs = feed?.exceptions ?: listOf(); val currentExs = feed?.exceptions ?: listOf();
if(currentExs != _lastExceptions && currentExs.any()) if(currentExs != _lastExceptions && currentExs.any()) {
handleExceptions(currentExs); handleExceptions(currentExs)
feed?.exceptions = listOf()
}
return@TaskHandler resp; return@TaskHandler resp;
}) })
@@ -151,6 +151,7 @@ class TutorialFragment : MainFragment() {
override val rating: IRating = RatingLikes(-1) override val rating: IRating = RatingLikes(-1)
override val viewCount: Long = -1 override val viewCount: Long = -1
override val video: IVideoSourceDescriptor = TutorialVideoSourceDescriptor(videoUrl, duration, width, height) override val video: IVideoSourceDescriptor = TutorialVideoSourceDescriptor(videoUrl, duration, width, height)
override val isShort: Boolean = false;
override fun getComments(client: IPlatformClient): IPager<IPlatformComment> { override fun getComments(client: IPlatformClient): IPager<IPlatformComment> {
return EmptyPager() return EmptyPager()
} }
@@ -1,17 +1,24 @@
package com.futo.platformplayer.fragment.mainactivity.main package com.futo.platformplayer.fragment.mainactivity.main
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.res.Configuration import android.content.res.Configuration
import android.database.ContentObserver
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.OrientationEventListener
import android.view.Surface
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowInsets import android.view.WindowInsets
import android.view.WindowInsetsController import android.view.WindowInsetsController
import android.view.WindowManager import android.view.WindowManager
import androidx.constraintlayout.motion.widget.MotionLayout import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.core.view.ViewCompat.getDisplay
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R import com.futo.platformplayer.R
@@ -28,13 +35,18 @@ import com.futo.platformplayer.models.PlatformVideoWithTime
import com.futo.platformplayer.models.UrlVideoWithTime import com.futo.platformplayer.models.UrlVideoWithTime
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.containers.SingleViewTouchableMotionLayout import com.futo.platformplayer.views.containers.SingleViewTouchableMotionLayout
import kotlin.math.min import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
//region Fragment
@UnstableApi @UnstableApi
class VideoDetailFragment : MainFragment { class VideoDetailFragment() : MainFragment() {
override val isMainView : Boolean = false; override val isMainView: Boolean = false;
override val hasBottomBar: Boolean = true; override val hasBottomBar: Boolean = true;
override val isOverlay : Boolean = true; override val isOverlay: Boolean = true;
override val isHistory: Boolean = false; override val isHistory: Boolean = false;
private var _isActive: Boolean = false; private var _isActive: Boolean = false;
@@ -76,8 +88,9 @@ class VideoDetailFragment : MainFragment {
private var _loadUrlOnCreate: UrlVideoWithTime? = null; private var _loadUrlOnCreate: UrlVideoWithTime? = null;
private var _leavingPiP = false; private var _leavingPiP = false;
//region Fragment private var _landscapeOrientationListener: LandscapeOrientationListener? = null
constructor() : super() private var _portraitOrientationListener: PortraitOrientationListener? = null
private var _autoRotateObserver: AutoRotateObserver? = null
fun nextVideo() { fun nextVideo() {
_viewDetail?.nextVideo(true, true, true); _viewDetail?.nextVideo(true, true, true);
@@ -88,23 +101,27 @@ class VideoDetailFragment : MainFragment {
} }
private fun isSmallWindow(): Boolean { private fun isSmallWindow(): Boolean {
return min( return resources.configuration.smallestScreenWidthDp < resources.getInteger(R.integer.column_width_dp) * 2
resources.configuration.screenWidthDp, }
resources.configuration.screenHeightDp
) < resources.getInteger(R.integer.column_width_dp) * 2 private fun isAutoRotateEnabled(): Boolean {
return android.provider.Settings.System.getInt(
context?.contentResolver,
android.provider.Settings.System.ACCELEROMETER_ROTATION, 0
) == 1
} }
override fun onConfigurationChanged(newConfig: Configuration) { override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig) super.onConfigurationChanged(newConfig)
val isLandscapeVideo: Boolean = _viewDetail?.isLandscapeVideo() ?: false val isLandscapeVideo: Boolean = _viewDetail?.isLandscapeVideo() ?: false
val isSmallWindow = isSmallWindow() val isSmallWindow = isSmallWindow()
if ( if (
isSmallWindow isSmallWindow
&& newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE && newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE
&& !isFullscreen && !isFullscreen
&& !isInPictureInPicture
&& state == State.MAXIMIZED && state == State.MAXIMIZED
) { ) {
_viewDetail?.setFullscreen(true) _viewDetail?.setFullscreen(true)
@@ -141,49 +158,93 @@ class VideoDetailFragment : MainFragment {
) { ) {
_viewDetail?.setFullscreen(true) _viewDetail?.setFullscreen(true)
} }
updateOrientation()
} }
@SuppressLint("SourceLockedOrientationActivity")
fun updateOrientation() { fun updateOrientation() {
val a = activity ?: return val a = activity ?: return
val isFullScreenPortraitAllowed = Settings.instance.playback.fullscreenPortrait val isFullScreenPortraitAllowed = Settings.instance.playback.fullscreenPortrait
val isReversePortraitAllowed = Settings.instance.playback.reversePortrait val isReversePortraitAllowed = Settings.instance.playback.reversePortrait
val rotationLock = StatePlayer.instance.rotationLock val rotationLock = StatePlayer.instance.rotationLock
val alwaysAllowReverseLandscapeAutoRotate = Settings.instance.playback.alwaysAllowReverseLandscapeAutoRotate
val isLandscapeVideo: Boolean = _viewDetail?.isLandscapeVideo() ?: false val isLandscapeVideo: Boolean = _viewDetail?.isLandscapeVideo() ?: true
val isSmallWindow = isSmallWindow() val isSmallWindow = isSmallWindow()
val autoRotateEnabled = isAutoRotateEnabled()
// For small windows if the device isn't landscape right now and full screen portrait isn't allowed then we should force landscape // For small windows if the device isn't landscape right now and full screen portrait isn't allowed then we should force landscape
if (isSmallWindow && isFullscreen && !isFullScreenPortraitAllowed && resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT && !rotationLock && isLandscapeVideo) { if (isSmallWindow && isFullscreen && !isFullScreenPortraitAllowed && resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT && !rotationLock && isLandscapeVideo) {
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE if (alwaysAllowReverseLandscapeAutoRotate){
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
} else {
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
}
if (autoRotateEnabled
) {
// start listening for the device to rotate to landscape
// at which point we'll be able to set requestedOrientation to back to UNSPECIFIED
_landscapeOrientationListener?.enableListener()
}
}
// For small windows if always all reverse landscape then we'll lock the orientation to landscape when system auto-rotate is off to make sure that locking
// and unlockiung in the player settings keep orientation in landscape
else if (isSmallWindow && isFullscreen && !isFullScreenPortraitAllowed && alwaysAllowReverseLandscapeAutoRotate && !rotationLock && isLandscapeVideo && !autoRotateEnabled) {
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
} }
// For small windows if the device isn't in a portrait orientation and we're in the maximized state then we should force portrait // For small windows if the device isn't in a portrait orientation and we're in the maximized state then we should force portrait
// only do this if auto-rotate is on portrait is forced when leaving full screen for autorotate off
else if (isSmallWindow && !isMinimizingFromFullScreen && !isFullscreen && state == State.MAXIMIZED && resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { else if (isSmallWindow && !isMinimizingFromFullScreen && !isFullscreen && state == State.MAXIMIZED && resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
@SuppressLint("SourceLockedOrientationActivity")
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
if (autoRotateEnabled
) {
// start listening for the device to rotate to portrait
// at which point we'll be able to set requestedOrientation to back to UNSPECIFIED
_portraitOrientationListener?.enableListener()
}
} else if (rotationLock) { } else if (rotationLock) {
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED _portraitOrientationListener?.disableListener()
_landscapeOrientationListener?.disableListener()
val display = getDisplay(_viewDetail!!)
val rotation = display!!.rotation
val orientation = resources.configuration.orientation
a.requestedOrientation = when (orientation) {
Configuration.ORIENTATION_PORTRAIT -> {
if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180) {
if (rotation == Surface.ROTATION_0) {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} else {
ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
}
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}
Configuration.ORIENTATION_LANDSCAPE -> {
if (rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270) {
if (rotation == Surface.ROTATION_90) {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
} else {
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
}
} else {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
}
else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
} else { } else {
when (Settings.instance.playback.autoRotate) { _portraitOrientationListener?.disableListener()
0 -> { _landscapeOrientationListener?.disableListener()
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED a.requestedOrientation = if (isReversePortraitAllowed) {
} ActivityInfo.SCREEN_ORIENTATION_FULL_USER
} else {
1 -> { ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
a.requestedOrientation = if (isReversePortraitAllowed) {
ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR
} else {
ActivityInfo.SCREEN_ORIENTATION_SENSOR
}
}
2 -> {
a.requestedOrientation = if (isReversePortraitAllowed) {
ActivityInfo.SCREEN_ORIENTATION_FULL_USER
} else {
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
} }
} }
} }
@@ -354,6 +415,30 @@ class VideoDetailFragment : MainFragment {
StatePlayer.instance.onRotationLockChanged.subscribe(this) { StatePlayer.instance.onRotationLockChanged.subscribe(this) {
updateOrientation() updateOrientation()
} }
val delayBeforeRemoveRotationLock = 800L
_landscapeOrientationListener = LandscapeOrientationListener(requireContext())
{
CoroutineScope(Dispatchers.Main).launch {
// delay to make sure that the system auto rotate updates
delay(delayBeforeRemoveRotationLock)
updateOrientation()
}
}
_portraitOrientationListener = PortraitOrientationListener(requireContext())
{
CoroutineScope(Dispatchers.Main).launch {
// delay to make sure that the system auto rotate updates
delay(delayBeforeRemoveRotationLock)
updateOrientation()
}
}
_autoRotateObserver = AutoRotateObserver(requireContext(), Handler(Looper.getMainLooper())) {
updateOrientation()
}
_autoRotateObserver?.startObserving()
return _view!!; return _view!!;
} }
@@ -455,6 +540,10 @@ class VideoDetailFragment : MainFragment {
SettingsActivity.settingsActivityClosed.remove(this) SettingsActivity.settingsActivityClosed.remove(this)
StatePlayer.instance.onRotationLockChanged.remove(this) StatePlayer.instance.onRotationLockChanged.remove(this)
_landscapeOrientationListener?.disableListener()
_portraitOrientationListener?.disableListener()
_autoRotateObserver?.stopObserving()
_viewDetail?.let { _viewDetail?.let {
_viewDetail = null; _viewDetail = null;
it.onDestroy(); it.onDestroy();
@@ -526,6 +615,11 @@ class VideoDetailFragment : MainFragment {
showSystemUI() showSystemUI()
} }
// temporarily force the device to portrait if auto-rotate is disabled to prevent landscape when exiting full screen on a small device
// @SuppressLint("SourceLockedOrientationActivity")
// if (!isFullscreen && isSmallWindow() && !isAutoRotateEnabled() && !isMinimizingFromFullScreen) {
// activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
// }
updateOrientation(); updateOrientation();
_view?.allowMotion = !fullscreen; _view?.allowMotion = !fullscreen;
} }
@@ -547,4 +641,88 @@ class VideoDetailFragment : MainFragment {
//region View //region View
//TODO: Determine if encapsulated would be readable enough //TODO: Determine if encapsulated would be readable enough
//endregion //endregion
} }
class LandscapeOrientationListener(
context: Context,
private val onLandscapeDetected: () -> Unit
) : OrientationEventListener(context) {
private var isListening = false
override fun onOrientationChanged(orientation: Int) {
if (!isListening) return
if (orientation in 60..120 || orientation in 240..300) {
onLandscapeDetected()
disableListener()
}
}
fun enableListener() {
if (!isListening) {
isListening = true
enable()
}
}
fun disableListener() {
if (isListening) {
isListening = false
disable()
}
}
}
class PortraitOrientationListener(
context: Context,
private val onPortraitDetected: () -> Unit
) : OrientationEventListener(context) {
private var isListening = false
override fun onOrientationChanged(orientation: Int) {
if (!isListening) return
if (orientation in 0..30 || orientation in 330..360 || orientation in 150..210) {
onPortraitDetected()
disableListener()
}
}
fun enableListener() {
if (!isListening) {
isListening = true
enable()
}
}
fun disableListener() {
if (isListening) {
isListening = false
disable()
}
}
}
class AutoRotateObserver(context: Context, handler: Handler, private val onAutoRotateChanged: () -> Unit) : ContentObserver(handler) {
private val contentResolver = context.contentResolver
override fun onChange(selfChange: Boolean) {
super.onChange(selfChange)
onAutoRotateChanged()
}
fun startObserving() {
contentResolver.registerContentObserver(
android.provider.Settings.System.getUriFor(android.provider.Settings.System.ACCELEROMETER_ROTATION),
false,
this
)
}
fun stopObserving() {
contentResolver.unregisterContentObserver(this)
}
}
@@ -94,12 +94,10 @@ import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.exceptions.UnsupportedCastException import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.fixHtmlLinks import com.futo.platformplayer.fixHtmlLinks
import com.futo.platformplayer.fixHtmlWhitespace import com.futo.platformplayer.fixHtmlWhitespace
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.getNowDiffSeconds import com.futo.platformplayer.getNowDiffSeconds
import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.receivers.MediaControlReceiver import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.AnnouncementType import com.futo.platformplayer.states.AnnouncementType
@@ -158,6 +156,8 @@ import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Models import com.futo.polycentric.core.Models
import com.futo.polycentric.core.Opinion import com.futo.polycentric.core.Opinion
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.protobuf.ByteString import com.google.protobuf.ByteString
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -171,7 +171,6 @@ import kotlinx.coroutines.withContext
import userpackage.Protocol import userpackage.Protocol
import java.time.OffsetDateTime import java.time.OffsetDateTime
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToLong import kotlin.math.roundToLong
@UnstableApi @UnstableApi
@@ -295,7 +294,7 @@ class VideoDetailView : ConstraintLayout {
private set; private set;
private var _historicalPosition: Long = 0; private var _historicalPosition: Long = 0;
private var _commentsCount = 0; private var _commentsCount = 0;
private var _polycentricProfile: PolycentricCache.CachedPolycentricProfile? = null; private var _polycentricProfile: PolycentricProfile? = null;
private var _slideUpOverlay: SlideUpMenuOverlay? = null; private var _slideUpOverlay: SlideUpMenuOverlay? = null;
private var _autoplayVideo: IPlatformVideo? = null private var _autoplayVideo: IPlatformVideo? = null
@@ -410,12 +409,12 @@ class VideoDetailView : ConstraintLayout {
}; };
_monetization.onSupportTap.subscribe { _monetization.onSupportTap.subscribe {
_container_content_support.setPolycentricProfile(_polycentricProfile?.profile); _container_content_support.setPolycentricProfile(_polycentricProfile);
switchContentView(_container_content_support); switchContentView(_container_content_support);
}; };
_monetization.onStoreTap.subscribe { _monetization.onStoreTap.subscribe {
_polycentricProfile?.profile?.systemState?.store?.let { _polycentricProfile?.systemState?.store?.let {
try { try {
val uri = Uri.parse(it); val uri = Uri.parse(it);
val intent = Intent(Intent.ACTION_VIEW); val intent = Intent(Intent.ACTION_VIEW);
@@ -580,6 +579,14 @@ class VideoDetailView : ConstraintLayout {
_minimize_title.setOnClickListener { onMaximize.emit(false) }; _minimize_title.setOnClickListener { onMaximize.emit(false) };
_minimize_meta.setOnClickListener { onMaximize.emit(false) }; _minimize_meta.setOnClickListener { onMaximize.emit(false) };
_player.onStateChange.subscribe {
if (_player.activelyPlaying) {
Logger.i(TAG, "Play changed, resetting error counter _didTriggerDatasourceErrorCount = 0 (_player.activelyPlaying: ${_player.activelyPlaying})")
_didTriggerDatasourceErrorCount = 0;
_didTriggerDatasourceError = false;
}
}
_player.onPlayChanged.subscribe { _player.onPlayChanged.subscribe {
if (StateCasting.instance.activeDevice == null) { if (StateCasting.instance.activeDevice == null) {
handlePlayChanged(it); handlePlayChanged(it);
@@ -649,18 +656,9 @@ class VideoDetailView : ConstraintLayout {
}; };
var hadDevice = false; var hadDevice = false;
StateSync.instance.deviceUpdatedOrAdded.subscribe(this) { id, session -> val devicesChanged = { id: String ->
val hasDevice = StateSync.instance.hasAtLeastOneOnlineDevice(); val hasDevice = StateSync.instance.hasAuthorizedDevice();
if(hasDevice != hadDevice) { if (hasDevice != hadDevice) {
hadDevice = hasDevice;
fragment.lifecycleScope.launch(Dispatchers.Main) {
updateMoreButtons();
}
}
};
StateSync.instance.deviceRemoved.subscribe(this) { id ->
val hasDevice = StateSync.instance.hasAtLeastOneOnlineDevice();
if(hasDevice != hadDevice) {
hadDevice = hasDevice; hadDevice = hasDevice;
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
updateMoreButtons(); updateMoreButtons();
@@ -668,6 +666,9 @@ class VideoDetailView : ConstraintLayout {
} }
} }
StateSync.instance.deviceUpdatedOrAdded.subscribe(this) { id, _ -> devicesChanged(id) };
StateSync.instance.deviceRemoved.subscribe(this) { id -> devicesChanged(id) };
MediaControlReceiver.onLowerVolumeReceived.subscribe(this) { handleLowerVolume() }; MediaControlReceiver.onLowerVolumeReceived.subscribe(this) { handleLowerVolume() };
MediaControlReceiver.onPlayReceived.subscribe(this) { handlePlay() }; MediaControlReceiver.onPlayReceived.subscribe(this) { handlePlay() };
MediaControlReceiver.onPauseReceived.subscribe(this) { handlePause() }; MediaControlReceiver.onPauseReceived.subscribe(this) { handlePause() };
@@ -876,22 +877,20 @@ class VideoDetailView : ConstraintLayout {
} }
_slideUpOverlay?.hide(); _slideUpOverlay?.hide();
} else null, } else null,
if(!isLimitedVersion) if (!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, if (allowBackground) context.getString(R.string.background_revert) else context.getString(R.string.background), TAG_BACKGROUND) {
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.background), TAG_BACKGROUND) { if (!allowBackground) {
if(!allowBackground) { _player.switchToAudioMode();
_player.switchToAudioMode(); allowBackground = true;
allowBackground = true; it.text.text = resources.getString(R.string.background_revert);
it.text.text = resources.getString(R.string.background_revert); } else {
} _player.switchToVideoMode();
else { allowBackground = false;
_player.switchToVideoMode(); it.text.text = resources.getString(R.string.background);
allowBackground = false;
it.text.text = resources.getString(R.string.background);
}
_slideUpOverlay?.hide();
} }
_slideUpOverlay?.hide();
}
else null, else null,
if(!isLimitedVersion) if(!isLimitedVersion && !(video?.isLive ?: false))
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) { RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
video?.let { video?.let {
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver); _slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
@@ -922,18 +921,25 @@ class VideoDetailView : ConstraintLayout {
}; };
_slideUpOverlay?.hide(); _slideUpOverlay?.hide();
}, },
if(StateSync.instance.hasAtLeastOneOnlineDevice()) { if (StateSync.instance.hasAuthorizedDevice()) {
RoundButton(context, R.drawable.ic_device, context.getString(R.string.send_to_device), TAG_SEND_TO_DEVICE) { RoundButton(context, R.drawable.ic_device, context.getString(R.string.send_to_device), TAG_SEND_TO_DEVICE) {
val devices = StateSync.instance.getSessions(); val devices = StateSync.instance.getAuthorizedSessions();
val videoToSend = video ?: return@RoundButton; val videoToSend = video ?: return@RoundButton;
if(devices.size > 1) { if(devices.size > 1) {
//not implemented //not implemented
} } else if(devices.size == 1){
else if(devices.size == 1){
val device = devices.first(); val device = devices.first();
UIDialogs.showConfirmationDialog(context, "Would you like to open\n[${videoToSend.name}]\non ${device.remotePublicKey}" , { Logger.i(TAG, "Send to device? (public key: ${device.remotePublicKey}): " + videoToSend.url)
UIDialogs.showConfirmationDialog(context, "Would you like to open\n[${videoToSend.name}]\non '${device.displayName}'" , {
Logger.i(TAG, "Send to device confirmed (public key: ${device.remotePublicKey}): " + videoToSend.url)
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
device.sendJsonData(GJSyncOpcodes.sendToDevices, SendToDevicePackage(videoToSend.url, (lastPositionMilliseconds/1000).toInt())); try {
device.sendJsonData(GJSyncOpcodes.sendToDevices, SendToDevicePackage(videoToSend.url, (lastPositionMilliseconds / 1000).toInt()))
Logger.i(TAG, "Send to device packet sent (public key: ${device.remotePublicKey}): " + videoToSend.url)
} catch (e: Throwable) {
Logger.e(TAG, "Send to device packet failed to send", e)
}
} }
}) })
} }
@@ -965,6 +971,7 @@ class VideoDetailView : ConstraintLayout {
throw IllegalStateException("Expected media content, found ${video.contentType}"); throw IllegalStateException("Expected media content, found ${video.contentType}");
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_videoResumePositionMilliseconds = _player.position
setVideoDetails(video); setVideoDetails(video);
} }
} }
@@ -1229,16 +1236,8 @@ class VideoDetailView : ConstraintLayout {
_creatorThumbnail.setThumbnail(video.author.thumbnail, false); _creatorThumbnail.setThumbnail(video.author.thumbnail, false);
_channelName.text = video.author.name; _channelName.text = video.author.name;
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(video.author.url, true); setPolycentricProfile(null, animate = false);
if (cachedPolycentricProfile != null) { _taskLoadPolycentricProfile.run(video.author.id);
setPolycentricProfile(cachedPolycentricProfile, animate = false);
if (cachedPolycentricProfile.expired) {
_taskLoadPolycentricProfile.run(video.author.id);
}
} else {
setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(video.author.id);
}
_player.clear(); _player.clear();
@@ -1267,8 +1266,6 @@ class VideoDetailView : ConstraintLayout {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
fun setVideoDetails(videoDetail: IPlatformVideoDetails, newVideo: Boolean = false) { fun setVideoDetails(videoDetail: IPlatformVideoDetails, newVideo: Boolean = false) {
Logger.i(TAG, "setVideoDetails (${videoDetail.name})") Logger.i(TAG, "setVideoDetails (${videoDetail.name})")
_didTriggerDatasourceErrroCount = 0;
_didTriggerDatasourceError = false;
_autoplayVideo = null _autoplayVideo = null
Logger.i(TAG, "Autoplay video cleared (setVideoDetails)") Logger.i(TAG, "Autoplay video cleared (setVideoDetails)")
@@ -1279,6 +1276,10 @@ class VideoDetailView : ConstraintLayout {
_lastVideoSource = null; _lastVideoSource = null;
_lastAudioSource = null; _lastAudioSource = null;
_lastSubtitleSource = null; _lastSubtitleSource = null;
Logger.i(TAG, "_didTriggerDatasourceErrorCount reset to 0 because new video")
_didTriggerDatasourceErrorCount = 0;
_didTriggerDatasourceError = false;
} }
if (videoDetail.datetime != null && videoDetail.datetime!! > OffsetDateTime.now()) if (videoDetail.datetime != null && videoDetail.datetime!! > OffsetDateTime.now())
@@ -1396,11 +1397,8 @@ class VideoDetailView : ConstraintLayout {
setTabIndex(2, true) setTabIndex(2, true)
} else { } else {
when (Settings.instance.comments.defaultCommentSection) { when (Settings.instance.comments.defaultCommentSection) {
0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex( 0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex(0, true) else setTabIndex(1, true)
0, 1 -> setTabIndex(1, true)
true
) else setTabIndex(1, true);
1 -> setTabIndex(1, true);
2 -> setTabIndex(StateMeta.instance.getLastCommentSection(), true) 2 -> setTabIndex(StateMeta.instance.getLastCommentSection(), true)
} }
} }
@@ -1438,16 +1436,8 @@ class VideoDetailView : ConstraintLayout {
_buttonSubscribe.setSubscribeChannel(video.author.url); _buttonSubscribe.setSubscribeChannel(video.author.url);
setDescription(video.description.fixHtmlLinks()); setDescription(video.description.fixHtmlLinks());
_creatorThumbnail.setThumbnail(video.author.thumbnail, false); _creatorThumbnail.setThumbnail(video.author.thumbnail, false);
setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(video.author.id);
val cachedPolycentricProfile =
PolycentricCache.instance.getCachedProfile(video.author.url, true);
if (cachedPolycentricProfile != null) {
setPolycentricProfile(cachedPolycentricProfile, animate = false);
} else {
setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(video.author.id);
}
_platform.setPlatformFromClientID(video.id.pluginId); _platform.setPlatformFromClientID(video.id.pluginId);
val subTitleSegments: ArrayList<String> = ArrayList(); val subTitleSegments: ArrayList<String> = ArrayList();
@@ -1476,7 +1466,7 @@ class VideoDetailView : ConstraintLayout {
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val queryReferencesResponse = ApiMethods.getQueryReferences( val queryReferencesResponse = ApiMethods.getQueryReferences(
PolycentricCache.SERVER, ref, null, null, ApiMethods.SERVER, ref, null, null,
arrayListOf( arrayListOf(
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
.setFromType(ContentType.OPINION.value).setValue( .setFromType(ContentType.OPINION.value).setValue(
@@ -1492,10 +1482,8 @@ class VideoDetailView : ConstraintLayout {
val likes = queryReferencesResponse.countsList[0]; val likes = queryReferencesResponse.countsList[0];
val dislikes = queryReferencesResponse.countsList[1]; val dislikes = queryReferencesResponse.countsList[1];
val hasLiked = val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/; val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
val hasDisliked =
StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_rating.visibility = View.VISIBLE; _rating.visibility = View.VISIBLE;
@@ -1833,7 +1821,7 @@ class VideoDetailView : ConstraintLayout {
} }
} }
private var _didTriggerDatasourceErrroCount = 0; private var _didTriggerDatasourceErrorCount = 0;
private var _didTriggerDatasourceError = false; private var _didTriggerDatasourceError = false;
private fun onDataSourceError(exception: Throwable) { private fun onDataSourceError(exception: Throwable) {
Logger.e(TAG, "onDataSourceError", exception); Logger.e(TAG, "onDataSourceError", exception);
@@ -1843,32 +1831,53 @@ class VideoDetailView : ConstraintLayout {
return; return;
val config = currentVideo.sourceConfig; val config = currentVideo.sourceConfig;
if(_didTriggerDatasourceErrroCount <= 3) { if(_didTriggerDatasourceErrorCount <= 3) {
_didTriggerDatasourceError = true; _didTriggerDatasourceError = true;
_didTriggerDatasourceErrroCount++; _didTriggerDatasourceErrorCount++;
UIDialogs.toast("Detected video error, attempting automatic reload (${_didTriggerDatasourceErrorCount})");
Logger.i(TAG, "Block detected, attempting bypass (_didTriggerDatasourceErrorCount = ${_didTriggerDatasourceErrorCount})");
UIDialogs.toast("Block detected, attempting bypass");
//return; //return;
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
val newDetails = StatePlatform.instance.getContentDetails(currentVideo.url, true).await(); try {
val previousVideoSource = _lastVideoSource; val newDetails = StatePlatform.instance.getContentDetails(currentVideo.url, true).await();
val previousAudioSource = _lastAudioSource; val previousVideoSource = _lastVideoSource;
val previousAudioSource = _lastAudioSource;
if(newDetails is IPlatformVideoDetails) { if (newDetails is IPlatformVideoDetails) {
val newVideoSource = if(previousVideoSource != null) val newVideoSource = if (previousVideoSource != null)
VideoHelper.selectBestVideoSource(newDetails.video, previousVideoSource.height * previousVideoSource.width, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS); VideoHelper.selectBestVideoSource(
else null; newDetails.video,
val newAudioSource = if(previousAudioSource != null) previousVideoSource.height * previousVideoSource.width,
VideoHelper.selectBestAudioSource(newDetails.video, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS, previousAudioSource.language, previousAudioSource.bitrate.toLong()); FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
else null; );
withContext(Dispatchers.Main) { else null;
video = newDetails; val newAudioSource = if (previousAudioSource != null)
_player.setSource(newVideoSource, newAudioSource, true, true); VideoHelper.selectBestAudioSource(
newDetails.video,
FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS,
previousAudioSource.language,
previousAudioSource.bitrate.toLong()
);
else null;
withContext(Dispatchers.Main) {
video = newDetails;
_player.setSource(newVideoSource, newAudioSource, true, true);
}
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get video details, attempting retrying without reloading.", e)
fragment.lifecycleScope.launch(Dispatchers.Main) {
video?.let {
_videoResumePositionMilliseconds = _player.position
setVideoDetails(it, false)
}
} }
} }
} }
} }
else if(_didTriggerDatasourceErrroCount > 3) { else if(_didTriggerDatasourceErrorCount > 3) {
UIDialogs.showDialog(context, R.drawable.ic_error_pred, UIDialogs.showDialog(context, R.drawable.ic_error_pred,
context.getString(R.string.media_error), context.getString(R.string.media_error),
context.getString(R.string.the_media_source_encountered_an_unauthorized_error_this_might_be_solved_by_a_plugin_reload_would_you_like_to_reload_experimental), context.getString(R.string.the_media_source_encountered_an_unauthorized_error_this_might_be_solved_by_a_plugin_reload_would_you_like_to_reload_experimental),
@@ -1900,13 +1909,45 @@ class VideoDetailView : ConstraintLayout {
return super.onInterceptTouchEvent(ev); return super.onInterceptTouchEvent(ev);
} }
//Actions //Actions
private fun showVideoSettings() { private fun showVideoSettings() {
Logger.i(TAG, "showVideoSettings") Logger.i(TAG, "showVideoSettings")
_overlay_quality_selector?.selectOption("video", _lastVideoSource); _overlay_quality_selector?.selectOption("video", _lastVideoSource);
_overlay_quality_selector?.selectOption("audio", _lastAudioSource); _overlay_quality_selector?.selectOption("audio", _lastAudioSource);
_overlay_quality_selector?.selectOption("subtitles", _lastSubtitleSource); _overlay_quality_selector?.selectOption("subtitles", _lastSubtitleSource);
if (_lastVideoSource is IDashManifestSource || _lastVideoSource is IHLSManifestSource) {
val videoTracks =
_player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO }
var selectedQuality: Format? = null
if (videoTracks != null) {
for (i in 0 until videoTracks.mediaTrackGroup.length) {
if (videoTracks.mediaTrackGroup.getFormat(i).height == _player.targetTrackVideoHeight) {
selectedQuality = videoTracks.mediaTrackGroup.getFormat(i)
}
}
}
var videoMenuGroup: SlideUpMenuGroup? = null
for (view in _overlay_quality_selector!!.groupItems) {
if (view is SlideUpMenuGroup && view.groupTag == "video") {
videoMenuGroup = view
}
}
if (selectedQuality != null) {
videoMenuGroup?.getItem("auto")?.setSubText("")
_overlay_quality_selector?.selectOption("video", selectedQuality)
} else {
videoMenuGroup?.getItem("auto")
?.setSubText("${_player.exoPlayer?.player?.videoFormat?.width}x${_player.exoPlayer?.player?.videoFormat?.height}")
_overlay_quality_selector?.selectOption("video", "auto")
}
}
val currentPlaybackRate = (if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()) ?: 1.0 val currentPlaybackRate = (if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()) ?: 1.0
_overlay_quality_selector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" }?.let { _overlay_quality_selector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" }?.let {
(it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString()) (it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString())
@@ -2080,17 +2121,15 @@ class VideoDetailView : ConstraintLayout {
call = { handleSelectSubtitleTrack(it) }) call = { handleSelectSubtitleTrack(it) })
}.toList().toTypedArray()) }.toList().toTypedArray())
else null, else null,
if(liveStreamVideoFormats?.isEmpty() == false) if (liveStreamVideoFormats?.isEmpty() == false) SlideUpMenuGroup(
SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video", this.context, context.getString(R.string.stream_video), "video", (listOf(
*liveStreamVideoFormats SlideUpMenuItem(this.context, R.drawable.ic_movie, "Auto", tag = "auto", call = { _player.selectVideoTrack(-1) })
.map { ) + (liveStreamVideoFormats.map {
SlideUpMenuItem(this.context, SlideUpMenuItem(this.context, R.drawable.ic_movie, it.label
R.drawable.ic_movie, ?: it.containerMimeType
it.label ?: it.containerMimeType ?: it.bitrate.toString(), ?: it.bitrate.toString(), "${it.width}x${it.height}", tag = it, call = { _player.selectVideoTrack(it.height) });
"${it.width}x${it.height}", }))
tag = it, )
call = { _player.selectVideoTrack(it.height) });
}.toList().toTypedArray())
else null, else null,
if(liveStreamAudioFormats?.isEmpty() == false) if(liveStreamAudioFormats?.isEmpty() == false)
SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio", SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio",
@@ -2384,8 +2423,13 @@ class VideoDetailView : ConstraintLayout {
} }
fun isLandscapeVideo(): Boolean? { fun isLandscapeVideo(): Boolean? {
val videoSourceWidth = _player.exoPlayer?.player?.videoSize?.width var videoSourceWidth = _player.exoPlayer?.player?.videoSize?.width
val videoSourceHeight = _player.exoPlayer?.player?.videoSize?.height var videoSourceHeight = _player.exoPlayer?.player?.videoSize?.height
if (video?.video?.videoSources?.isNotEmpty() == true && (videoSourceWidth == null || videoSourceHeight == null || videoSourceWidth == 0 || videoSourceHeight == 0)) {
videoSourceWidth = video?.video?.videoSources!![0].width
videoSourceHeight = video?.video?.videoSources!![0].height
}
return if (videoSourceWidth == null || videoSourceHeight == null || videoSourceWidth == 0 || videoSourceHeight == 0){ return if (videoSourceWidth == null || videoSourceHeight == null || videoSourceWidth == 0 || videoSourceHeight == 0){
null null
@@ -2562,8 +2606,13 @@ class VideoDetailView : ConstraintLayout {
onAddToWatchLaterClicked.subscribe(this) { onAddToWatchLaterClicked.subscribe(this) {
if(it is IPlatformVideo) { if(it is IPlatformVideo) {
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true); if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true))
UIDialogs.toast("Added to watch later\n[${it.name}]"); UIDialogs.toast("Added to watch later\n[${it.name}]");
}
}
onAddToQueueClicked.subscribe(this) {
if(it is IPlatformVideo) {
StatePlayer.instance.addToQueue(it);
} }
} }
}) })
@@ -2735,13 +2784,12 @@ class VideoDetailView : ConstraintLayout {
} }
} }
private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { private fun setPolycentricProfile(profile: PolycentricProfile?, animate: Boolean) {
_polycentricProfile = cachedPolycentricProfile; _polycentricProfile = profile
val dp_35 = 35.dp(context.resources) val dp_35 = 35.dp(context.resources)
val profile = cachedPolycentricProfile?.profile;
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_35 * dp_35) val avatar = profile?.systemState?.avatar?.selectBestImage(dp_35 * dp_35)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) }; ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) }
if (avatar != null) { if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, animate); _creatorThumbnail.setThumbnail(avatar, animate);
@@ -2750,12 +2798,12 @@ class VideoDetailView : ConstraintLayout {
_creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto()); _creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
} }
val username = cachedPolycentricProfile?.profile?.systemState?.username val username = profile?.systemState?.username
if (username != null) { if (username != null) {
_channelName.text = username _channelName.text = username
} }
_monetization.setPolycentricProfile(cachedPolycentricProfile); _monetization.setPolycentricProfile(profile);
} }
fun setProgressBarOverlayed(isOverlayed: Boolean?) { fun setProgressBarOverlayed(isOverlayed: Boolean?) {
@@ -2943,7 +2991,7 @@ class VideoDetailView : ConstraintLayout {
Logger.w(TAG, "Failed to load recommendations.", it); Logger.w(TAG, "Failed to load recommendations.", it);
}; };
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>(StateApp.instance.scopeGetter, { PolycentricCache.instance.getProfileAsync(it) }) private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) })
.success { it -> setPolycentricProfile(it, animate = true) } .success { it -> setPolycentricProfile(it, animate = true) }
.exception<Throwable> { .exception<Throwable> {
Logger.w(TAG, "Failed to load claims.", it); Logger.w(TAG, "Failed to load claims.", it);
@@ -20,6 +20,8 @@ import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.toHumanDuration
import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.views.lists.VideoListEditorView import com.futo.platformplayer.views.lists.VideoListEditorView
abstract class VideoListEditorView : LinearLayout { abstract class VideoListEditorView : LinearLayout {
@@ -32,6 +34,7 @@ abstract class VideoListEditorView : LinearLayout {
protected var overlayContainer: FrameLayout protected var overlayContainer: FrameLayout
private set; private set;
protected var _buttonDownload: ImageButton; protected var _buttonDownload: ImageButton;
protected var _buttonExport: ImageButton;
private var _buttonShare: ImageButton; private var _buttonShare: ImageButton;
private var _buttonEdit: ImageButton; private var _buttonEdit: ImageButton;
@@ -52,6 +55,8 @@ abstract class VideoListEditorView : LinearLayout {
_buttonEdit = findViewById(R.id.button_edit); _buttonEdit = findViewById(R.id.button_edit);
_buttonDownload = findViewById(R.id.button_download); _buttonDownload = findViewById(R.id.button_download);
_buttonDownload.visibility = View.GONE; _buttonDownload.visibility = View.GONE;
_buttonExport = findViewById(R.id.button_export);
_buttonExport.visibility = View.GONE;
_buttonShare = findViewById(R.id.button_share); _buttonShare = findViewById(R.id.button_share);
val onShare = _onShare; val onShare = _onShare;
@@ -66,6 +71,7 @@ abstract class VideoListEditorView : LinearLayout {
buttonShuffle.setOnClickListener { onShuffleClick(); }; buttonShuffle.setOnClickListener { onShuffleClick(); };
_buttonEdit.setOnClickListener { onEditClick(); }; _buttonEdit.setOnClickListener { onEditClick(); };
setButtonExportVisible(false);
setButtonDownloadVisible(canEdit()); setButtonDownloadVisible(canEdit());
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged); videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
@@ -106,6 +112,7 @@ abstract class VideoListEditorView : LinearLayout {
_buttonDownload.setBackgroundResource(R.drawable.background_button_round); _buttonDownload.setBackgroundResource(R.drawable.background_button_round);
if(isDownloading) { if(isDownloading) {
setButtonExportVisible(false);
_buttonDownload.setImageResource(R.drawable.ic_loader_animated); _buttonDownload.setImageResource(R.drawable.ic_loader_animated);
_buttonDownload.drawable.assume<Animatable, Unit> { it.start() }; _buttonDownload.drawable.assume<Animatable, Unit> { it.start() };
_buttonDownload.setOnClickListener { _buttonDownload.setOnClickListener {
@@ -115,6 +122,7 @@ abstract class VideoListEditorView : LinearLayout {
} }
} }
else if(isDownloaded) { else if(isDownloaded) {
setButtonExportVisible(true)
_buttonDownload.setImageResource(R.drawable.ic_download_off); _buttonDownload.setImageResource(R.drawable.ic_download_off);
_buttonDownload.setOnClickListener { _buttonDownload.setOnClickListener {
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), { UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
@@ -123,6 +131,7 @@ abstract class VideoListEditorView : LinearLayout {
} }
} }
else { else {
setButtonExportVisible(false);
_buttonDownload.setImageResource(R.drawable.ic_download); _buttonDownload.setImageResource(R.drawable.ic_download);
_buttonDownload.setOnClickListener { _buttonDownload.setOnClickListener {
onDownload(); onDownload();
@@ -136,8 +145,14 @@ abstract class VideoListEditorView : LinearLayout {
_textName.text = name ?: ""; _textName.text = name ?: "";
} }
protected fun setVideoCount(videoCount: Int = -1) { protected fun setMetadata(videoCount: Int = -1, duration: Long = -1) {
_textMetadata.text = if (videoCount == -1) "" else "${videoCount} " + context.getString(R.string.videos); val parts = mutableListOf<String>()
if(videoCount >= 0)
parts.add("${videoCount} " + context.getString(R.string.videos));
if(duration > 0)
parts.add("${duration.toHumanDuration(false)} ");
_textMetadata.text = parts.joinToString("");
} }
protected fun setVideos(videos: List<IPlatformVideo>?, canEdit: Boolean) { protected fun setVideos(videos: List<IPlatformVideo>?, canEdit: Boolean) {
@@ -163,6 +178,9 @@ abstract class VideoListEditorView : LinearLayout {
protected fun setButtonDownloadVisible(isVisible: Boolean) { protected fun setButtonDownloadVisible(isVisible: Boolean) {
_buttonDownload.visibility = if (isVisible) View.VISIBLE else View.GONE; _buttonDownload.visibility = if (isVisible) View.VISIBLE else View.GONE;
} }
protected fun setButtonExportVisible(isVisible: Boolean) {
_buttonExport.visibility = if (isVisible) View.VISIBLE else View.GONE;
}
protected fun setButtonEditVisible(isVisible: Boolean) { protected fun setButtonEditVisible(isVisible: Boolean) {
_buttonEdit.visibility = if (isVisible) View.VISIBLE else View.GONE; _buttonEdit.visibility = if (isVisible) View.VISIBLE else View.GONE;
@@ -14,9 +14,9 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.views.casting.CastButton import com.futo.platformplayer.views.casting.CastButton
import com.futo.polycentric.core.PolycentricProfile
class NavigationTopBarFragment : TopFragment() { class NavigationTopBarFragment : TopFragment() {
private var _buttonBack: ImageButton? = null; private var _buttonBack: ImageButton? = null;
@@ -13,16 +13,15 @@ import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlWidevineSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IWidevineSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.Language import com.futo.platformplayer.others.Language
@@ -47,8 +46,8 @@ class VideoHelper {
return false return false
} }
fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource; fun isDownloadable(source: IVideoSource) = (source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource) && source !is IWidevineSource
fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource || source is JSDashManifestRawAudioSource) && source !is IAudioUrlWidevineSource fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource || source is JSDashManifestRawAudioSource) && source !is IWidevineSource
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers); fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers);
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? { fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {
@@ -1,5 +1,7 @@
package com.futo.platformplayer.images; package com.futo.platformplayer.images;
import static com.futo.platformplayer.Extensions_PolycentricKt.getDataLinkFromUrl;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@@ -12,10 +14,14 @@ import com.bumptech.glide.load.model.ModelLoader;
import com.bumptech.glide.load.model.ModelLoaderFactory; import com.bumptech.glide.load.model.ModelLoaderFactory;
import com.bumptech.glide.load.model.MultiModelLoaderFactory; import com.bumptech.glide.load.model.MultiModelLoaderFactory;
import com.bumptech.glide.signature.ObjectKey; import com.bumptech.glide.signature.ObjectKey;
import com.futo.platformplayer.polycentric.PolycentricCache; import com.futo.polycentric.core.ApiMethods;
import kotlin.Unit; import kotlin.Unit;
import kotlinx.coroutines.CoroutineScopeKt;
import kotlinx.coroutines.Deferred; import kotlinx.coroutines.Deferred;
import kotlinx.coroutines.Dispatchers;
import userpackage.Protocol;
import java.lang.Exception; import java.lang.Exception;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.util.concurrent.CancellationException; import java.util.concurrent.CancellationException;
@@ -60,7 +66,14 @@ public class PolycentricModelLoader implements ModelLoader<String, ByteBuffer> {
@Override @Override
public void loadData(@NonNull Priority priority, @NonNull DataFetcher.DataCallback<? super ByteBuffer> callback) { public void loadData(@NonNull Priority priority, @NonNull DataFetcher.DataCallback<? super ByteBuffer> callback) {
Log.i("PolycentricModelLoader", this._model); Log.i("PolycentricModelLoader", this._model);
_deferred = PolycentricCache.getInstance().getDataAsync(_model);
Protocol.URLInfoDataLink dataLink = getDataLinkFromUrl(_model);
if (dataLink == null) {
callback.onLoadFailed(new Exception("Data link cannot be null"));
return;
}
_deferred = ApiMethods.Companion.getDataFromServerAndReassemble(CoroutineScopeKt.CoroutineScope(Dispatchers.getIO()), dataLink);
_deferred.invokeOnCompletion(throwable -> { _deferred.invokeOnCompletion(throwable -> {
if (throwable != null) { if (throwable != null) {
Log.e("PolycentricModelLoader", "getDataAsync failed throwable: " + throwable.toString()); Log.e("PolycentricModelLoader", "getDataAsync failed throwable: " + throwable.toString());
@@ -55,21 +55,25 @@ class ServiceRecordAggregator {
if (_cts != null) throw Exception("Already started.") if (_cts != null) throw Exception("Already started.")
_cts = CoroutineScope(Dispatchers.Default).launch { _cts = CoroutineScope(Dispatchers.Default).launch {
while (isActive) { try {
val now = Date() while (isActive) {
synchronized(_currentServices) { val now = Date()
_cachedAddressRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } } synchronized(_currentServices) {
_cachedTxtRecords.entries.removeIf { now.after(it.value.expirationTime) } _cachedAddressRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } }
_cachedSrvRecords.entries.removeIf { now.after(it.value.expirationTime) } _cachedTxtRecords.entries.removeIf { now.after(it.value.expirationTime) }
_cachedPtrRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } } _cachedSrvRecords.entries.removeIf { now.after(it.value.expirationTime) }
_cachedPtrRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } }
val newServices = getCurrentServices() val newServices = getCurrentServices()
_currentServices.clear() _currentServices.clear()
_currentServices.addAll(newServices) _currentServices.addAll(newServices)
}
onServicesUpdated?.invoke(_currentServices.toList())
delay(5000)
} }
} catch (e: Throwable) {
onServicesUpdated?.invoke(_currentServices.toList()) Logger.e(TAG, "Unexpected failure in MDNS loop", e)
delay(5000)
} }
} }
} }
@@ -83,6 +87,7 @@ class ServiceRecordAggregator {
} }
fun add(packet: DnsPacket) { fun add(packet: DnsPacket) {
val currentServices: List<DnsService>
val dnsResourceRecords = packet.answers + packet.additionals + packet.authorities val dnsResourceRecords = packet.answers + packet.additionals + packet.authorities
val txtRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.TXT.value.toInt() }.map { it to it.getDataReader().readTXTRecord() } val txtRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.TXT.value.toInt() }.map { it to it.getDataReader().readTXTRecord() }
val aRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.A.value.toInt() }.map { it to it.getDataReader().readARecord() } val aRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.A.value.toInt() }.map { it to it.getDataReader().readARecord() }
@@ -99,35 +104,33 @@ class ServiceRecordAggregator {
aaaaRecords.forEach { builder.appendLine("AAAA ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") } aaaaRecords.forEach { builder.appendLine("AAAA ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") }
Logger.i(TAG, "$builder")*/ Logger.i(TAG, "$builder")*/
val currentServices: MutableList<DnsService>
ptrRecords.forEach { record ->
val cachedPtrRecord = _cachedPtrRecords.getOrPut(record.first.name) { mutableListOf() }
val newPtrRecord = CachedDnsPtrRecord(Date(System.currentTimeMillis() + record.first.timeToLive.toLong() * 1000L), record.second.domainName)
cachedPtrRecord.replaceOrAdd(newPtrRecord) { it.target == record.second.domainName }
}
aRecords.forEach { aRecord ->
val cachedARecord = _cachedAddressRecords.getOrPut(aRecord.first.name) { mutableListOf() }
val newARecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aRecord.first.timeToLive.toLong() * 1000L), aRecord.second.address)
cachedARecord.replaceOrAdd(newARecord) { it.address == newARecord.address }
}
aaaaRecords.forEach { aaaaRecord ->
val cachedAaaaRecord = _cachedAddressRecords.getOrPut(aaaaRecord.first.name) { mutableListOf() }
val newAaaaRecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aaaaRecord.first.timeToLive.toLong() * 1000L), aaaaRecord.second.address)
cachedAaaaRecord.replaceOrAdd(newAaaaRecord) { it.address == newAaaaRecord.address }
}
txtRecords.forEach { txtRecord ->
_cachedTxtRecords[txtRecord.first.name] = CachedDnsTxtRecord(Date(System.currentTimeMillis() + txtRecord.first.timeToLive.toLong() * 1000L), txtRecord.second.texts)
}
srvRecords.forEach { srvRecord ->
_cachedSrvRecords[srvRecord.first.name] = CachedDnsSrvRecord(Date(System.currentTimeMillis() + srvRecord.first.timeToLive.toLong() * 1000L), srvRecord.second)
}
//TODO: Maybe this can be debounced?
synchronized(this._currentServices) { synchronized(this._currentServices) {
ptrRecords.forEach { record ->
val cachedPtrRecord = _cachedPtrRecords.getOrPut(record.first.name) { mutableListOf() }
val newPtrRecord = CachedDnsPtrRecord(Date(System.currentTimeMillis() + record.first.timeToLive.toLong() * 1000L), record.second.domainName)
cachedPtrRecord.replaceOrAdd(newPtrRecord) { it.target == record.second.domainName }
}
aRecords.forEach { aRecord ->
val cachedARecord = _cachedAddressRecords.getOrPut(aRecord.first.name) { mutableListOf() }
val newARecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aRecord.first.timeToLive.toLong() * 1000L), aRecord.second.address)
cachedARecord.replaceOrAdd(newARecord) { it.address == newARecord.address }
}
aaaaRecords.forEach { aaaaRecord ->
val cachedAaaaRecord = _cachedAddressRecords.getOrPut(aaaaRecord.first.name) { mutableListOf() }
val newAaaaRecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aaaaRecord.first.timeToLive.toLong() * 1000L), aaaaRecord.second.address)
cachedAaaaRecord.replaceOrAdd(newAaaaRecord) { it.address == newAaaaRecord.address }
}
txtRecords.forEach { txtRecord ->
_cachedTxtRecords[txtRecord.first.name] = CachedDnsTxtRecord(Date(System.currentTimeMillis() + txtRecord.first.timeToLive.toLong() * 1000L), txtRecord.second.texts)
}
srvRecords.forEach { srvRecord ->
_cachedSrvRecords[srvRecord.first.name] = CachedDnsSrvRecord(Date(System.currentTimeMillis() + srvRecord.first.timeToLive.toLong() * 1000L), srvRecord.second)
}
currentServices = getCurrentServices() currentServices = getCurrentServices()
this._currentServices.clear() this._currentServices.clear()
this._currentServices.addAll(currentServices) this._currentServices.addAll(currentServices)
@@ -59,7 +59,7 @@ class PlatformLinkMovementMethod(private val _context: Context) : LinkMovementMe
if (linkPressed && pressedLinks != null) { if (linkPressed && pressedLinks != null) {
val dx = event.x - downX val dx = event.x - downX
val dy = event.y - downY val dy = event.y - downY
if (Math.abs(dx) <= touchSlop && Math.abs(dy) <= touchSlop) { if (Math.abs(dx) <= touchSlop && Math.abs(dy) <= touchSlop && isTouchInside(widget, event)) {
runBlocking { runBlocking {
for (link in pressedLinks!!) { for (link in pressedLinks!!) {
Logger.i(TAG) { "Link clicked '${link.url}'." } Logger.i(TAG) { "Link clicked '${link.url}'." }
@@ -101,7 +101,7 @@ class PlatformLinkMovementMethod(private val _context: Context) : LinkMovementMe
} }
} }
return super.onTouchEvent(widget, buffer, event) return false
} }
private fun findLinksAtTouchPosition(widget: TextView, buffer: Spannable, event: MotionEvent): Array<URLSpan> { private fun findLinksAtTouchPosition(widget: TextView, buffer: Spannable, event: MotionEvent): Array<URLSpan> {
@@ -114,6 +114,10 @@ class PlatformLinkMovementMethod(private val _context: Context) : LinkMovementMe
return buffer.getSpans(off, off, URLSpan::class.java) return buffer.getSpans(off, off, URLSpan::class.java)
} }
private fun isTouchInside(widget: TextView, event: MotionEvent): Boolean {
return event.x >= 0 && event.x <= widget.width && event.y >= 0 && event.y <= widget.height
}
companion object { companion object {
const val TAG = "PlatformLinkMovementMethod" const val TAG = "PlatformLinkMovementMethod"
} }
@@ -1,353 +0,0 @@
package com.futo.platformplayer.polycentric
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.constructs.BatchedTaskHandler
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.getNowDiffSeconds
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.resolveChannelUrls
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.stores.CachedPolycentricProfileStorage
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.OwnedClaim
import com.futo.polycentric.core.PublicKey
import com.futo.polycentric.core.SignedEvent
import com.futo.polycentric.core.StorageTypeSystemState
import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.base64ToByteArray
import com.futo.polycentric.core.base64UrlToByteArray
import com.futo.polycentric.core.getClaimIfValid
import com.futo.polycentric.core.getValidClaims
import com.google.protobuf.ByteString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.serialization.Serializable
import userpackage.Protocol
import java.nio.ByteBuffer
import java.time.OffsetDateTime
import kotlin.system.measureTimeMillis
class PolycentricCache {
data class CachedOwnedClaims(val ownedClaims: List<OwnedClaim>?, val creationTime: OffsetDateTime = OffsetDateTime.now()) {
val expired get() = creationTime.getNowDiffSeconds() > CACHE_EXPIRATION_SECONDS
}
@Serializable
data class CachedPolycentricProfile(val profile: PolycentricProfile?, @Serializable(with = OffsetDateTimeSerializer::class) val creationTime: OffsetDateTime = OffsetDateTime.now()) {
val expired get() = creationTime.getNowDiffSeconds() > CACHE_EXPIRATION_SECONDS
}
private val _cache = hashMapOf<PlatformID, CachedOwnedClaims>()
private val _profileCache = hashMapOf<PublicKey, CachedPolycentricProfile>()
private val _profileUrlCache: CachedPolycentricProfileStorage;
private val _scope = CoroutineScope(Dispatchers.IO);
init {
Logger.i(TAG, "Initializing Polycentric cache");
val time = measureTimeMillis {
_profileUrlCache = FragmentedStorage.get<CachedPolycentricProfileStorage>("profileUrlCache")
}
Logger.i(TAG, "Initialized Polycentric cache (${_profileUrlCache.map.size}, ${time}ms)");
}
private val _taskGetProfile = BatchedTaskHandler<PublicKey, CachedPolycentricProfile>(_scope,
{ system ->
val signedEventsList = ApiMethods.getQueryLatest(
SERVER,
system.toProto(),
listOf(
ContentType.BANNER.value,
ContentType.AVATAR.value,
ContentType.USERNAME.value,
ContentType.DESCRIPTION.value,
ContentType.STORE.value,
ContentType.SERVER.value,
ContentType.STORE_DATA.value,
ContentType.PROMOTION_BANNER.value,
ContentType.PROMOTION.value,
ContentType.MEMBERSHIP_URLS.value,
ContentType.DONATION_DESTINATIONS.value
)
).eventsList.map { e -> SignedEvent.fromProto(e) };
val signedProfileEvents = signedEventsList.groupBy { e -> e.event.contentType }
.map { (_, events) -> events.maxBy { it.event.unixMilliseconds ?: 0 } };
val storageSystemState = StorageTypeSystemState.create()
for (signedEvent in signedProfileEvents) {
storageSystemState.update(signedEvent.event)
}
val signedClaimEvents = ApiMethods.getQueryIndex(
SERVER,
system.toProto(),
ContentType.CLAIM.value,
limit = 200
).eventsList.map { e -> SignedEvent.fromProto(e) };
val ownedClaims: ArrayList<OwnedClaim> = arrayListOf()
for (signedEvent in signedClaimEvents) {
if (signedEvent.event.contentType != ContentType.CLAIM.value) {
continue;
}
val response = ApiMethods.getQueryReferences(
SERVER,
Protocol.Reference.newBuilder()
.setReference(signedEvent.toPointer().toProto().toByteString())
.setReferenceType(2)
.build(),
null,
Protocol.QueryReferencesRequestEvents.newBuilder()
.setFromType(ContentType.VOUCH.value)
.build()
);
val ownedClaim = response.itemsList.map { SignedEvent.fromProto(it.event) }.getClaimIfValid(signedEvent);
if (ownedClaim != null) {
ownedClaims.add(ownedClaim);
}
}
Logger.i(TAG, "Retrieved profile (ownedClaims = $ownedClaims)");
val systemState = SystemState.fromStorageTypeSystemState(storageSystemState);
return@BatchedTaskHandler CachedPolycentricProfile(PolycentricProfile(system, systemState, ownedClaims));
},
{ system -> return@BatchedTaskHandler getCachedProfile(system); },
{ system, result ->
synchronized(_cache) {
_profileCache[system] = result;
if (result.profile != null) {
for (claim in result.profile.ownedClaims) {
val urls = claim.claim.resolveChannelUrls();
for (url in urls)
_profileUrlCache.map[url] = result;
}
}
_profileUrlCache.save();
}
});
private val _batchTaskGetClaims = BatchedTaskHandler<PlatformID, CachedOwnedClaims>(_scope,
{ id ->
val resolved = if (id.claimFieldType == -1) ApiMethods.getResolveClaim(SERVER, system, id.claimType.toLong(), id.value!!)
else ApiMethods.getResolveClaim(SERVER, system, id.claimType.toLong(), id.claimFieldType.toLong(), id.value!!);
Logger.v(TAG, "getResolveClaim(url = $SERVER, system = $system, id = $id, claimType = ${id.claimType}, matchAnyField = ${id.value})");
val protoEvents = resolved.matchesList.flatMap { arrayListOf(it.claim).apply { addAll(it.proofChainList) } }
val resolvedEvents = protoEvents.map { i -> SignedEvent.fromProto(i) };
return@BatchedTaskHandler CachedOwnedClaims(resolvedEvents.getValidClaims());
},
{ id -> return@BatchedTaskHandler getCachedValidClaims(id); },
{ id, result ->
synchronized(_cache) {
_cache[id] = result;
}
});
private val _batchTaskGetData = BatchedTaskHandler<String, ByteBuffer>(_scope,
{
val dataLink = getDataLinkFromUrl(it) ?: throw Exception("Only URLInfoDataLink is supported");
return@BatchedTaskHandler ApiMethods.getDataFromServerAndReassemble(dataLink);
},
{ return@BatchedTaskHandler null },
{ _, _ -> });
fun getCachedValidClaims(id: PlatformID, ignoreExpired: Boolean = false): CachedOwnedClaims? {
if (!StatePolycentric.instance.enabled || id.claimType <= 0) {
return CachedOwnedClaims(null);
}
synchronized(_cache) {
val cached = _cache[id]
if (cached == null) {
return null
}
if (!ignoreExpired && cached.expired) {
return null;
}
return cached;
}
}
//TODO: Review all return null in this file, perhaps it should be CachedX(null) instead
fun getValidClaimsAsync(id: PlatformID): Deferred<CachedOwnedClaims> {
if (!StatePolycentric.instance.enabled || id.value == null || id.claimType <= 0) {
return _scope.async { CachedOwnedClaims(null) };
}
Logger.v(TAG, "getValidClaims (id: $id)")
val def = _batchTaskGetClaims.execute(id);
def.invokeOnCompletion {
if (it == null) {
return@invokeOnCompletion
}
handleException(it, handleNetworkException = { /* Do nothing (do not cache) */ }, handleOtherException = {
//Cache failed result
synchronized(_cache) {
_cache[id] = CachedOwnedClaims(null);
}
})
};
return def;
}
fun getDataAsync(url: String): Deferred<ByteBuffer> {
StatePolycentric.instance.ensureEnabled()
return _batchTaskGetData.execute(url);
}
fun getCachedProfile(url: String, ignoreExpired: Boolean = false): CachedPolycentricProfile? {
if (!StatePolycentric.instance.enabled) {
return CachedPolycentricProfile(null)
}
synchronized (_profileCache) {
val cached = _profileUrlCache.get(url) ?: return null;
if (!ignoreExpired && cached.expired) {
return null;
}
return cached;
}
}
fun getCachedProfile(system: PublicKey, ignoreExpired: Boolean = false): CachedPolycentricProfile? {
if (!StatePolycentric.instance.enabled) {
return CachedPolycentricProfile(null)
}
synchronized(_profileCache) {
val cached = _profileCache[system] ?: return null;
if (!ignoreExpired && cached.expired) {
return null;
}
return cached;
}
}
suspend fun getProfileAsync(id: PlatformID, urlNullCache: String? = null): CachedPolycentricProfile? {
if (!StatePolycentric.instance.enabled || id.claimType <= 0) {
return CachedPolycentricProfile(null);
}
val cachedClaims = getCachedValidClaims(id);
if (cachedClaims != null) {
if (!cachedClaims.ownedClaims.isNullOrEmpty()) {
Logger.v(TAG, "getProfileAsync (id: $id) != null (with cached valid claims)")
return getProfileAsync(cachedClaims.ownedClaims.first().system).await();
} else {
return null;
}
} else {
Logger.v(TAG, "getProfileAsync (id: $id) no cached valid claims, will be retrieved")
val claims = getValidClaimsAsync(id).await()
if (!claims.ownedClaims.isNullOrEmpty()) {
Logger.v(TAG, "getProfileAsync (id: $id) != null (with retrieved valid claims)")
return getProfileAsync(claims.ownedClaims.first().system).await()
} else {
synchronized (_cache) {
if (urlNullCache != null) {
_profileUrlCache.setAndSave(urlNullCache, CachedPolycentricProfile(null))
}
}
return null;
}
}
}
fun getProfileAsync(system: PublicKey): Deferred<CachedPolycentricProfile?> {
if (!StatePolycentric.instance.enabled) {
return _scope.async { CachedPolycentricProfile(null) };
}
Logger.i(TAG, "getProfileAsync (system: ${system})")
val def = _taskGetProfile.execute(system);
def.invokeOnCompletion {
if (it == null) {
return@invokeOnCompletion
}
handleException(it, handleNetworkException = { /* Do nothing (do not cache) */ }, handleOtherException = {
//Cache failed result
synchronized(_cache) {
val cachedProfile = CachedPolycentricProfile(null);
_profileCache[system] = cachedProfile;
}
})
};
return def;
}
private fun handleException(e: Throwable, handleNetworkException: () -> Unit, handleOtherException: () -> Unit) {
val isNetworkException = when(e) {
is java.net.UnknownHostException,
is java.net.SocketTimeoutException,
is java.net.ConnectException -> true
else -> when(e.cause) {
is java.net.UnknownHostException,
is java.net.SocketTimeoutException,
is java.net.ConnectException -> true
else -> false
}
}
if (isNetworkException) {
handleNetworkException()
} else {
handleOtherException()
}
}
companion object {
private val system = Protocol.PublicKey.newBuilder()
.setKeyType(1)
.setKey(ByteString.copyFrom("gX0eCWctTm6WHVGot4sMAh7NDAIwWsIM5tRsOz9dX04=".base64ToByteArray())) //Production key
//.setKey(ByteString.copyFrom("LeQkzn1j625YZcZHayfCmTX+6ptrzsA+CdAyq+BcEdQ".base64ToByteArray())) //Test key koen-futo
.build();
private const val TAG = "PolycentricCache"
const val SERVER = "https://srv1-prod.polycentric.io"
private var _instance: PolycentricCache? = null;
private val CACHE_EXPIRATION_SECONDS = 60 * 5;
@JvmStatic
val instance: PolycentricCache
get(){
if(_instance == null)
_instance = PolycentricCache();
return _instance!!;
};
fun finish() {
_instance?.let {
_instance = null;
it._scope.cancel("PolycentricCache finished");
}
}
fun getDataLinkFromUrl(it: String): Protocol.URLInfoDataLink? {
val urlData = if (it.startsWith("polycentric://")) {
it.substring("polycentric://".length)
} else it;
val urlBytes = urlData.base64UrlToByteArray();
val urlInfo = Protocol.URLInfo.parseFrom(urlBytes);
if (urlInfo.urlType != 4L) {
return null
}
val dataLink = Protocol.URLInfoDataLink.parseFrom(urlInfo.body);
return dataLink
}
}
}
@@ -160,10 +160,6 @@ class StateApp {
private var _cacheDirectory: File? = null; private var _cacheDirectory: File? = null;
private var _persistentDirectory: File? = null; private var _persistentDirectory: File? = null;
//AutoRotate
var systemAutoRotate: Boolean = false;
//Network //Network
private var _lastMeteredState: Boolean = false; private var _lastMeteredState: Boolean = false;
private var _connectivityManager: ConnectivityManager? = null; private var _connectivityManager: ConnectivityManager? = null;
@@ -201,17 +197,6 @@ class StateApp {
return File(_persistentDirectory, name); return File(_persistentDirectory, name);
} }
fun getCurrentSystemAutoRotate(): Boolean {
_context?.let {
systemAutoRotate = android.provider.Settings.System.getInt(
it.contentResolver,
android.provider.Settings.System.ACCELEROMETER_ROTATION, 0
) == 1;
};
return systemAutoRotate;
}
fun isCurrentMetered(): Boolean { fun isCurrentMetered(): Boolean {
ensureConnectivityManager(); ensureConnectivityManager();
return _connectivityManager?.isActiveNetworkMetered ?: throw IllegalStateException("Connectivity manager not available"); return _connectivityManager?.isActiveNetworkMetered ?: throw IllegalStateException("Connectivity manager not available");
@@ -312,9 +297,6 @@ class StateApp {
fun setGlobalContext(context: Context, coroutineScope: CoroutineScope? = null) { fun setGlobalContext(context: Context, coroutineScope: CoroutineScope? = null) {
_context = context; _context = context;
_scope = coroutineScope _scope = coroutineScope
//System checks
systemAutoRotate = getCurrentSystemAutoRotate();
} }
fun initializeFiles(force: Boolean = false) { fun initializeFiles(force: Boolean = false) {
@@ -1,6 +1,7 @@
package com.futo.platformplayer.states package com.futo.platformplayer.states
import android.content.Context import android.content.Context
import com.futo.platformplayer.logging.Logger
import kotlin.streams.asSequence import kotlin.streams.asSequence
/*** /***
@@ -45,10 +46,16 @@ class StateAssets {
var text: String?; var text: String?;
synchronized(_cache) { synchronized(_cache) {
if (!_cache.containsKey(path)) { if (!_cache.containsKey(path)) {
text = context.assets try {
?.open(path) text = context.assets
?.bufferedReader() ?.open(path)
?.use { it.readText(); }; ?.bufferedReader()
?.use { it.readText(); };
}
catch(ex: Throwable) {
Logger.e("StateAssets", "Could not open asset: " + path, ex);
return null;
}
_cache.put(path, text); _cache.put(path, text);
} else { } else {
@@ -6,7 +6,6 @@ import com.futo.platformplayer.api.media.structures.DedupContentPager
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.serializers.PlatformContentSerializer import com.futo.platformplayer.serializers.PlatformContentSerializer
import com.futo.platformplayer.stores.db.ManagedDBStore import com.futo.platformplayer.stores.db.ManagedDBStore
@@ -50,14 +49,7 @@ class StateCache {
val subs = StateSubscriptions.instance.getSubscriptions(); val subs = StateSubscriptions.instance.getSubscriptions();
Logger.i(TAG, "Subscriptions CachePager polycentric urls"); Logger.i(TAG, "Subscriptions CachePager polycentric urls");
val allUrls = subs val allUrls = subs
.map { .map { it.channel.url }
val otherUrls = PolycentricCache.instance.getCachedProfile(it.channel.url)?.profile?.ownedClaims?.mapNotNull { c -> c.claim.resolveChannelUrl() } ?: listOf();
if(!otherUrls.contains(it.channel.url))
return@map listOf(listOf(it.channel.url), otherUrls).flatten();
else
return@map otherUrls;
}
.flatten()
.distinct() .distinct()
.filter { StatePlatform.instance.hasEnabledChannelClient(it) }; .filter { StatePlatform.instance.hasEnabledChannelClient(it) };
@@ -3,9 +3,11 @@ package com.futo.platformplayer.states
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.os.StatFs import android.os.StatFs
import androidx.documentfile.provider.DocumentFile
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
@@ -46,6 +48,17 @@ class StateDownloads {
private val _downloadsStat = StatFs(_downloadsDirectory.absolutePath); private val _downloadsStat = StatFs(_downloadsDirectory.absolutePath);
private val _downloaded = FragmentedStorage.storeJson<VideoLocal>("downloaded") private val _downloaded = FragmentedStorage.storeJson<VideoLocal>("downloaded")
.withOnModified({
synchronized(_downloadedSet) {
if(!_downloadedSet.contains(it.id))
_downloadedSet.add(it.id);
}
}, {
synchronized(_downloadedSet) {
if(_downloadedSet.contains(it.id))
_downloadedSet.remove(it.id);
}
})
.load() .load()
.apply { afterLoadingDownloaded(this) }; .apply { afterLoadingDownloaded(this) };
private val _downloading = FragmentedStorage.storeJson<VideoDownload>("downloading") private val _downloading = FragmentedStorage.storeJson<VideoDownload>("downloading")
@@ -85,9 +98,6 @@ class StateDownloads {
Logger.i("StateDownloads", "Deleting local video ${id.value}"); Logger.i("StateDownloads", "Deleting local video ${id.value}");
val downloaded = getCachedVideo(id); val downloaded = getCachedVideo(id);
if(downloaded != null) { if(downloaded != null) {
synchronized(_downloadedSet) {
_downloadedSet.remove(id);
}
_downloaded.delete(downloaded); _downloaded.delete(downloaded);
} }
onDownloadedChanged.emit(); onDownloadedChanged.emit();
@@ -261,9 +271,6 @@ class StateDownloads {
if(existing.groupID == null) { if(existing.groupID == null) {
existing.groupID = VideoDownload.GROUP_WATCHLATER; existing.groupID = VideoDownload.GROUP_WATCHLATER;
existing.groupType = VideoDownload.GROUP_WATCHLATER; existing.groupType = VideoDownload.GROUP_WATCHLATER;
synchronized(_downloadedSet) {
_downloadedSet.add(existing.id);
}
_downloaded.save(existing); _downloaded.save(existing);
} }
} }
@@ -306,9 +313,6 @@ class StateDownloads {
if(existing.groupID == null) { if(existing.groupID == null) {
existing.groupID = playlist.id; existing.groupID = playlist.id;
existing.groupType = VideoDownload.GROUP_PLAYLIST; existing.groupType = VideoDownload.GROUP_PLAYLIST;
synchronized(_downloadedSet) {
_downloadedSet.add(existing.id);
}
_downloaded.save(existing); _downloaded.save(existing);
} }
} }
@@ -466,6 +470,65 @@ class StateDownloads {
return _downloadsDirectory; return _downloadsDirectory;
} }
fun exportPlaylist(context: Context, playlistId: String) {
if(context is IWithResultLauncher)
StateApp.instance.requestDirectoryAccess(context, "Export Playlist", "To export playlist to directory", null) {
if (it == null)
return@requestDirectoryAccess;
val root = DocumentFile.fromTreeUri(context, it!!);
val playlist = StatePlaylists.instance.getPlaylist(playlistId);
var localVideos = StateDownloads.instance.getDownloadedVideosPlaylist(playlistId);
if(playlist != null) {
val missing = playlist.videos
.filter { vid -> !localVideos.any { it.id.value == null || it.id.value == vid.id.value } }
.map { getCachedVideo(it.id) }
.filterNotNull();
if(missing.size > 0)
localVideos = localVideos + missing;
};
var lastNotifyTime = -1L;
UIDialogs.showDialogProgress(context) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
it.setText("Exporting videos..");
var i = 0;
var success = 0;
for (video in localVideos) {
withContext(Dispatchers.Main) {
it.setText("Exporting videos...(${i}/${localVideos.size})");
//it.setProgress(i.toDouble() / localVideos.size);
}
try {
val export = VideoExport(video, video.videoSource.firstOrNull(), video.audioSource.firstOrNull(), video.subtitlesSources.firstOrNull());
Logger.i(TAG, "Exporting [${export.videoLocal.name}] started");
val file = export.export(context, { progress ->
val now = System.currentTimeMillis();
if (lastNotifyTime == -1L || now - lastNotifyTime > 100) {
it.setProgress(progress);
lastNotifyTime = now;
}
}, root);
success++;
} catch(ex: Throwable) {
Logger.e(TAG, "Failed export [${video.name}]: ${ex.message}", ex);
}
i++;
}
withContext(Dispatchers.Main) {
it.setProgress(1f);
it.dismiss();
UIDialogs.appToast("Finished exporting playlist (${success} videos${if(i < success) ", ${i} errors" else ""})");
}
};
}
}
}
fun export(context: Context, videoLocal: VideoLocal, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?) { fun export(context: Context, videoLocal: VideoLocal, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?) {
var lastNotifyTime = -1L; var lastNotifyTime = -1L;
@@ -477,13 +540,13 @@ class StateDownloads {
try { try {
Logger.i(TAG, "Exporting [${export.videoLocal.name}] started"); Logger.i(TAG, "Exporting [${export.videoLocal.name}] started");
val file = export.export(context) { progress -> val file = export.export(context, { progress ->
val now = System.currentTimeMillis(); val now = System.currentTimeMillis();
if (lastNotifyTime == -1L || now - lastNotifyTime > 100) { if (lastNotifyTime == -1L || now - lastNotifyTime > 100) {
it.setProgress(progress); it.setProgress(progress);
lastNotifyTime = now; lastNotifyTime = now;
} }
} }, null);
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
it.setProgress(100.0f) it.setProgress(100.0f)
@@ -8,6 +8,7 @@ import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.states.StatePlaylists.Companion
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.db.ManagedDBStore import com.futo.platformplayer.stores.db.ManagedDBStore
import com.futo.platformplayer.stores.db.types.DBHistory import com.futo.platformplayer.stores.db.types.DBHistory
@@ -89,12 +90,14 @@ class StateHistory {
if(isUserAction && _lastHistoryBroadcast != historyBroadcastSig) { if(isUserAction && _lastHistoryBroadcast != historyBroadcastSig) {
_lastHistoryBroadcast = historyBroadcastSig; _lastHistoryBroadcast = historyBroadcastSig;
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) { try {
Logger.i(TAG, "SyncHistory playback broadcasted (${liveObj.name}: ${position})"); Logger.i(TAG, "SyncHistory playback broadcasted (${liveObj.name}: ${position})");
StateSync.instance.broadcastJsonData( StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncHistory, GJSyncOpcodes.syncHistory,
listOf(historyVideo) listOf(historyVideo)
); );
} catch (e: Throwable) {
Logger.e(StatePlaylists.TAG, "Failed to broadcast sync history", e)
} }
}; };
} }
@@ -14,7 +14,7 @@ class StateMeta {
return when(lastCommentSection.value){ return when(lastCommentSection.value){
"Polycentric" -> 0; "Polycentric" -> 0;
"Platform" -> 1; "Platform" -> 1;
else -> 1 else -> 0
} }
} }
fun setLastCommentSection(value: Int) { fun setLastCommentSection(value: Int) {
@@ -13,6 +13,7 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -130,6 +131,12 @@ class StatePlayer {
closeMediaSession(); closeMediaSession();
} }
fun saveQueueAsPlaylist(name: String){
val videos = _queue.toList();
val playlist = Playlist(name, videos.map { SerializedPlatformVideo.fromVideo(it) });
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
}
//Notifications //Notifications
fun hasMediaSession() : Boolean { fun hasMediaSession() : Boolean {
return MediaPlaybackService.getService() != null; return MediaPlaybackService.getService() != null;
@@ -5,6 +5,7 @@ import android.net.Uri
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
@@ -176,8 +177,11 @@ class StatePlaylists {
StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER); StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER);
} }
} }
fun addToWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false, orderPosition: Int = -1) { fun addToWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false, orderPosition: Int = -1): Boolean {
var wasNew = false;
synchronized(_watchlistStore) { synchronized(_watchlistStore) {
if(!_watchlistStore.hasItem { it.url == video.url })
wasNew = true;
_watchlistStore.saveAsync(video); _watchlistStore.saveAsync(video);
if(orderPosition == -1) if(orderPosition == -1)
_watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values) .toTypedArray()); _watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values) .toTypedArray());
@@ -197,6 +201,7 @@ class StatePlaylists {
} }
StateDownloads.instance.checkForOutdatedPlaylists(); StateDownloads.instance.checkForOutdatedPlaylists();
return wasNew;
} }
fun getLastPlayedPlaylist() : Playlist? { fun getLastPlayedPlaylist() : Playlist? {
@@ -227,31 +232,50 @@ class StatePlaylists {
private fun broadcastWatchLater(orderOnly: Boolean = false) { private fun broadcastWatchLater(orderOnly: Boolean = false) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
StateSync.instance.broadcastJsonData(GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage( try {
if(orderOnly) listOf() else getWatchLater(), StateSync.instance.broadcastJsonData(
if(orderOnly) mapOf() else _watchLaterAdds.all(), GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
if(orderOnly) mapOf() else _watchLaterRemovals.all(), if (orderOnly) listOf() else getWatchLater(),
getWatchLaterLastReorderTime().toEpochSecond(), if (orderOnly) mapOf() else _watchLaterAdds.all(),
_watchlistOrderStore.values.toList())); if (orderOnly) mapOf() else _watchLaterRemovals.all(),
getWatchLaterLastReorderTime().toEpochSecond(),
_watchlistOrderStore.values.toList()
)
);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to broadcast watch later", e)
}
}; };
} }
private fun broadcastWatchLaterAddition(video: SerializedPlatformVideo, time: OffsetDateTime) { private fun broadcastWatchLaterAddition(video: SerializedPlatformVideo, time: OffsetDateTime) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
StateSync.instance.broadcastJsonData(GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage( try {
listOf(video), StateSync.instance.broadcastJsonData(
mapOf(Pair(video.url, time.toEpochSecond())), GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
mapOf(), listOf(video),
mapOf(Pair(video.url, time.toEpochSecond())),
mapOf(),
)) )
)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to broadcast watch later addition", e)
}
}; };
} }
private fun broadcastWatchLaterRemoval(url: String, time: OffsetDateTime) { private fun broadcastWatchLaterRemoval(url: String, time: OffsetDateTime) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
StateSync.instance.broadcastJsonData(GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage( try {
listOf(), StateSync.instance.broadcastJsonData(
mapOf(), GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
mapOf(Pair(url, time.toEpochSecond())) listOf(),
)) mapOf(),
mapOf(Pair(url, time.toEpochSecond()))
)
)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to broadcast watch later removal", e)
}
}; };
} }
@@ -287,25 +311,32 @@ class StatePlaylists {
broadcastSyncPlaylist(playlist); broadcastSyncPlaylist(playlist);
} }
} }
fun addToPlaylist(id: String, video: IPlatformVideo) { fun addToPlaylist(id: String, video: IPlatformVideo): Boolean {
synchronized(playlistStore) { synchronized(playlistStore) {
val playlist = getPlaylist(id) ?: return; val playlist = getPlaylist(id) ?: return false;
if(!Settings.instance.other.playlistAllowDups && playlist.videos.any { it.url == video.url })
return false;
playlist.videos.add(SerializedPlatformVideo.fromVideo(video)); playlist.videos.add(SerializedPlatformVideo.fromVideo(video));
playlist.dateUpdate = OffsetDateTime.now(); playlist.dateUpdate = OffsetDateTime.now();
playlistStore.saveAsync(playlist, true); playlistStore.saveAsync(playlist, true);
broadcastSyncPlaylist(playlist); broadcastSyncPlaylist(playlist);
return true;
} }
} }
private fun broadcastSyncPlaylist(playlist: Playlist){ private fun broadcastSyncPlaylist(playlist: Playlist){
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) { try {
Logger.i(StateSubscriptionGroups.TAG, "SyncPlaylist (${playlist.name})"); Logger.i(StateSubscriptionGroups.TAG, "SyncPlaylist (${playlist.name})");
StateSync.instance.broadcastJsonData( StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncPlaylists, GJSyncOpcodes.syncPlaylists,
SyncPlaylistsPackage(listOf(playlist), mapOf()) SyncPlaylistsPackage(listOf(playlist), mapOf())
); );
} catch (e: Throwable) {
Logger.e(TAG, "Failed to broadcast sync playlist", e)
} }
}; };
} }
@@ -319,12 +350,14 @@ class StatePlaylists {
_playlistRemoved.setAndSave(playlist.id, OffsetDateTime.now()); _playlistRemoved.setAndSave(playlist.id, OffsetDateTime.now());
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) { try {
Logger.i(StateSubscriptionGroups.TAG, "SyncPlaylist (${playlist.name})"); Logger.i(StateSubscriptionGroups.TAG, "SyncPlaylist (${playlist.name})");
StateSync.instance.broadcastJsonData( StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncPlaylists, GJSyncOpcodes.syncPlaylists,
SyncPlaylistsPackage(listOf(), mapOf(Pair(playlist.id, OffsetDateTime.now().toEpochSecond()))) SyncPlaylistsPackage(listOf(), mapOf(Pair(playlist.id, OffsetDateTime.now().toEpochSecond())))
); );
} catch (e: Throwable) {
Logger.e(TAG, "Failed to broadcast sync playlists", e)
} }
}; };
} }
@@ -2,6 +2,7 @@ package com.futo.platformplayer.states
import android.content.Context import android.content.Context
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.LoginActivity import com.futo.platformplayer.activities.LoginActivity
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
@@ -101,6 +102,8 @@ class StatePlugins {
if (availableClient !is JSClient) { if (availableClient !is JSClient) {
continue continue
} }
if(!Settings.instance.plugins.checkDisabledPluginsForUpdates && !StatePlatform.instance.isClientEnabled(availableClient.id))
continue;
val newConfig = checkForUpdates(availableClient.config); val newConfig = checkForUpdates(availableClient.config);
if (newConfig != null) { if (newConfig != null) {
@@ -21,9 +21,7 @@ import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
import com.futo.platformplayer.awaitFirstDeferred import com.futo.platformplayer.awaitFirstDeferred
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.polycentric.PolycentricStorage import com.futo.platformplayer.polycentric.PolycentricStorage
import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
@@ -33,6 +31,7 @@ import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ClaimType import com.futo.polycentric.core.ClaimType
import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Opinion import com.futo.polycentric.core.Opinion
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.ProcessHandle import com.futo.polycentric.core.ProcessHandle
import com.futo.polycentric.core.PublicKey import com.futo.polycentric.core.PublicKey
import com.futo.polycentric.core.SignedEvent import com.futo.polycentric.core.SignedEvent
@@ -234,34 +233,7 @@ class StatePolycentric {
if (!enabled) { if (!enabled) {
return Pair(false, listOf(url)); return Pair(false, listOf(url));
} }
var polycentricProfile: PolycentricProfile? = null; return Pair(didUpdate, listOf(url));
try {
val polycentricCached = PolycentricCache.instance.getCachedProfile(url, cacheOnly)
polycentricProfile = polycentricCached?.profile;
if (polycentricCached == null && channelId != null) {
Logger.i("StateSubscriptions", "Get polycentric profile not cached");
if(!cacheOnly) {
polycentricProfile = runBlocking { PolycentricCache.instance.getProfileAsync(channelId, if(doCacheNull) url else null) }?.profile;
didUpdate = true;
}
} else {
Logger.i("StateSubscriptions", "Get polycentric profile cached");
}
}
catch(ex: Throwable) {
Logger.w(StateSubscriptions.TAG, "Polycentric getCachedProfile failed for subscriptions", ex);
//TODO: Some way to communicate polycentric failing without blocking here
}
if(polycentricProfile != null) {
val urls = polycentricProfile.ownedClaims.groupBy { it.claim.claimType }
.mapNotNull { it.value.firstOrNull()?.claim?.resolveChannelUrl() }.toMutableList();
if(urls.any { it.equals(url, true) })
return Pair(didUpdate, urls);
else
return Pair(didUpdate, listOf(url) + urls);
}
else
return Pair(didUpdate, listOf(url));
} }
fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager<IPlatformContent>? { fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager<IPlatformContent>? {
@@ -325,7 +297,7 @@ class StatePolycentric {
id = PlatformID("polycentric", author, null, ClaimType.POLYCENTRIC.value.toInt()), id = PlatformID("polycentric", author, null, ClaimType.POLYCENTRIC.value.toInt()),
name = systemState.username, name = systemState.username,
url = author, url = author,
thumbnail = systemState.avatar?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(system.toProto(), img.process, listOf(PolycentricCache.SERVER)) }, thumbnail = systemState.avatar?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(system.toProto(), img.process, listOf(ApiMethods.SERVER)) },
subscribers = null subscribers = null
), ),
msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content, msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content,
@@ -349,7 +321,7 @@ class StatePolycentric {
suspend fun getLikesDislikesReplies(reference: Protocol.Reference): LikesDislikesReplies { suspend fun getLikesDislikesReplies(reference: Protocol.Reference): LikesDislikesReplies {
ensureEnabled() ensureEnabled()
val response = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, null, val response = ApiMethods.getQueryReferences(ApiMethods.SERVER, reference, null,
null, null,
listOf( listOf(
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
@@ -382,7 +354,7 @@ class StatePolycentric {
} }
val pointer = Protocol.Pointer.parseFrom(reference.reference) val pointer = Protocol.Pointer.parseFrom(reference.reference)
val events = ApiMethods.getEvents(PolycentricCache.SERVER, pointer.system, Protocol.RangesForSystem.newBuilder() val events = ApiMethods.getEvents(ApiMethods.SERVER, pointer.system, Protocol.RangesForSystem.newBuilder()
.addRangesForProcesses(Protocol.RangesForProcess.newBuilder() .addRangesForProcesses(Protocol.RangesForProcess.newBuilder()
.setProcess(pointer.process) .setProcess(pointer.process)
.addRanges(Protocol.Range.newBuilder() .addRanges(Protocol.Range.newBuilder()
@@ -400,11 +372,11 @@ class StatePolycentric {
} }
val post = Protocol.Post.parseFrom(ev.content); val post = Protocol.Post.parseFrom(ev.content);
val systemLinkUrl = ev.system.systemToURLInfoSystemLinkUrl(listOf(PolycentricCache.SERVER)); val systemLinkUrl = ev.system.systemToURLInfoSystemLinkUrl(listOf(ApiMethods.SERVER));
val dp_25 = 25.dp(StateApp.instance.context.resources) val dp_25 = 25.dp(StateApp.instance.context.resources)
val profileEvents = ApiMethods.getQueryLatest( val profileEvents = ApiMethods.getQueryLatest(
PolycentricCache.SERVER, ApiMethods.SERVER,
ev.system.toProto(), ev.system.toProto(),
listOf( listOf(
ContentType.AVATAR.value, ContentType.AVATAR.value,
@@ -433,7 +405,7 @@ class StatePolycentric {
id = PlatformID("polycentric", systemLinkUrl, null, ClaimType.POLYCENTRIC.value.toInt()), id = PlatformID("polycentric", systemLinkUrl, null, ClaimType.POLYCENTRIC.value.toInt()),
name = nameEvent?.event?.lwwElement?.value?.decodeToString() ?: "Unknown", name = nameEvent?.event?.lwwElement?.value?.decodeToString() ?: "Unknown",
url = systemLinkUrl, url = systemLinkUrl,
thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(PolycentricCache.SERVER)) }, thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(ApiMethods.SERVER)) },
subscribers = null subscribers = null
), ),
msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content, msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content,
@@ -445,12 +417,12 @@ class StatePolycentric {
) )
} }
suspend fun getCommentPager(contextUrl: String, reference: Protocol.Reference, extraByteReferences: List<ByteArray>? = null): IPager<IPlatformComment> { suspend fun getCommentPager(contextUrl: String, reference: Reference, extraByteReferences: List<ByteArray>? = null): IPager<IPlatformComment> {
if (!enabled) { if (!enabled) {
return EmptyPager() return EmptyPager()
} }
val response = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, null, val response = ApiMethods.getQueryReferences(ApiMethods.SERVER, reference, null,
Protocol.QueryReferencesRequestEvents.newBuilder() Protocol.QueryReferencesRequestEvents.newBuilder()
.setFromType(ContentType.POST.value) .setFromType(ContentType.POST.value)
.addAllCountLwwElementReferences(arrayListOf( .addAllCountLwwElementReferences(arrayListOf(
@@ -486,7 +458,7 @@ class StatePolycentric {
} }
override suspend fun nextPageAsync() { override suspend fun nextPageAsync() {
val nextPageResponse = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, _cursor, val nextPageResponse = ApiMethods.getQueryReferences(ApiMethods.SERVER, reference, _cursor,
Protocol.QueryReferencesRequestEvents.newBuilder() Protocol.QueryReferencesRequestEvents.newBuilder()
.setFromType(ContentType.POST.value) .setFromType(ContentType.POST.value)
.addAllCountLwwElementReferences(arrayListOf( .addAllCountLwwElementReferences(arrayListOf(
@@ -534,7 +506,7 @@ class StatePolycentric {
return@mapNotNull LazyComment(scope.async(_commentPoolDispatcher){ return@mapNotNull LazyComment(scope.async(_commentPoolDispatcher){
Logger.i(TAG, "Fetching comment data for [" + ev.system.key.toBase64() + "]"); Logger.i(TAG, "Fetching comment data for [" + ev.system.key.toBase64() + "]");
val profileEvents = ApiMethods.getQueryLatest( val profileEvents = ApiMethods.getQueryLatest(
PolycentricCache.SERVER, ApiMethods.SERVER,
ev.system.toProto(), ev.system.toProto(),
listOf( listOf(
ContentType.AVATAR.value, ContentType.AVATAR.value,
@@ -558,7 +530,7 @@ class StatePolycentric {
val unixMilliseconds = ev.unixMilliseconds val unixMilliseconds = ev.unixMilliseconds
//TODO: Don't use single hardcoded sderver here //TODO: Don't use single hardcoded sderver here
val systemLinkUrl = ev.system.systemToURLInfoSystemLinkUrl(listOf(PolycentricCache.SERVER)); val systemLinkUrl = ev.system.systemToURLInfoSystemLinkUrl(listOf(ApiMethods.SERVER));
val dp_25 = 25.dp(StateApp.instance.context.resources) val dp_25 = 25.dp(StateApp.instance.context.resources)
return@async PolycentricPlatformComment( return@async PolycentricPlatformComment(
contextUrl = contextUrl, contextUrl = contextUrl,
@@ -566,7 +538,7 @@ class StatePolycentric {
id = PlatformID("polycentric", systemLinkUrl, null, ClaimType.POLYCENTRIC.value.toInt()), id = PlatformID("polycentric", systemLinkUrl, null, ClaimType.POLYCENTRIC.value.toInt()),
name = nameEvent?.event?.lwwElement?.value?.decodeToString() ?: "Unknown", name = nameEvent?.event?.lwwElement?.value?.decodeToString() ?: "Unknown",
url = systemLinkUrl, url = systemLinkUrl,
thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(PolycentricCache.SERVER)) }, thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(ApiMethods.SERVER)) },
subscribers = null subscribers = null
), ),
msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content, msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content,
@@ -1,54 +1,17 @@
package com.futo.platformplayer.states package com.futo.platformplayer.states
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
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.*
import com.futo.platformplayer.api.media.structures.ReusablePager.Companion.asReusable
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptCriticalException
import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.findNonRuntimeException
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.getNowDiffDays
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.states.StateHistory.Companion
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringDateMapStorage import com.futo.platformplayer.stores.StringDateMapStorage
import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ReconstructStore
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithms
import com.futo.platformplayer.sync.internal.GJSyncOpcodes import com.futo.platformplayer.sync.internal.GJSyncOpcodes
import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage
import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.* import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.concurrent.ExecutionException
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask
import kotlin.collections.ArrayList
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlin.streams.asSequence
import kotlin.streams.toList
import kotlin.system.measureTimeMillis
/*** /***
* Used to maintain subscription groups * Used to maintain subscription groups
@@ -79,12 +42,14 @@ class StateSubscriptionGroups {
onGroupsChanged.emit(); onGroupsChanged.emit();
if(!preventSync) { if(!preventSync) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) { try {
Logger.i(TAG, "SyncSubscriptionGroup (${subGroup.name})"); Logger.i(TAG, "SyncSubscriptionGroup (${subGroup.name})");
StateSync.instance.broadcastJsonData( StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncSubscriptionGroups, GJSyncOpcodes.syncSubscriptionGroups,
SyncSubscriptionGroupsPackage(listOf(subGroup), mapOf()) SyncSubscriptionGroupsPackage(listOf(subGroup), mapOf())
); );
} catch (e: Throwable) {
Logger.e(TAG, "Failed to broadcast update subscription group", e)
} }
}; };
} }
@@ -98,12 +63,14 @@ class StateSubscriptionGroups {
if(isUserInteraction) { if(isUserInteraction) {
_groupsRemoved.setAndSave(id, OffsetDateTime.now()); _groupsRemoved.setAndSave(id, OffsetDateTime.now());
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
if(StateSync.instance.hasAtLeastOneOnlineDevice()) { try {
Logger.i(TAG, "SyncSubscriptionGroup delete (${group.name})"); Logger.i(TAG, "SyncSubscriptionGroup delete (${group.name})");
StateSync.instance.broadcastJsonData( StateSync.instance.broadcastJsonData(
GJSyncOpcodes.syncSubscriptionGroups, GJSyncOpcodes.syncSubscriptionGroups,
SyncSubscriptionGroupsPackage(listOf(), mapOf(Pair(id, OffsetDateTime.now().toEpochSecond()))) SyncSubscriptionGroupsPackage(listOf(), mapOf(Pair(id, OffsetDateTime.now().toEpochSecond())))
); );
} catch (e: Throwable) {
Logger.e(TAG, "Failed to delete subscription group", e)
} }
}; };
} }
@@ -15,7 +15,6 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringDateMapStorage import com.futo.platformplayer.stores.StringDateMapStorage
@@ -335,12 +334,6 @@ class StateSubscriptions {
return true; return true;
} }
//TODO: This causes issues, because what if the profile is not cached yet when the susbcribe button is loaded for example?
val cachedProfile = PolycentricCache.instance.getCachedProfile(urls.first(), true)?.profile;
if (cachedProfile != null) {
return cachedProfile.ownedClaims.any { c -> _subscriptions.hasItem { s -> c.claim.resolveChannelUrl() == s.channel.url } };
}
return false; return false;
} }
} }
@@ -44,6 +44,7 @@ import kotlin.system.measureTimeMillis
class StateSync { class StateSync {
private val _authorizedDevices = FragmentedStorage.get<StringArrayStorage>("authorized_devices") private val _authorizedDevices = FragmentedStorage.get<StringArrayStorage>("authorized_devices")
private val _nameStorage = FragmentedStorage.get<StringStringMapStorage>("sync_remembered_name_storage")
private val _syncKeyPair = FragmentedStorage.get<StringStorage>("sync_key_pair") private val _syncKeyPair = FragmentedStorage.get<StringStorage>("sync_key_pair")
private val _lastAddressStorage = FragmentedStorage.get<StringStringMapStorage>("sync_last_address_storage") private val _lastAddressStorage = FragmentedStorage.get<StringStringMapStorage>("sync_last_address_storage")
private val _syncSessionData = FragmentedStorage.get<StringTMapStorage<SyncSessionData>>("syncSessionData") private val _syncSessionData = FragmentedStorage.get<StringTMapStorage<SyncSessionData>>("syncSessionData")
@@ -65,6 +66,12 @@ class StateSync {
val deviceRemoved: Event1<String> = Event1() val deviceRemoved: Event1<String> = Event1()
val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2() val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2()
fun hasAuthorizedDevice(): Boolean {
synchronized(_sessions) {
return _sessions.any{ it.value.connected && it.value.isAuthorized };
}
}
fun start() { fun start() {
if (_started) { if (_started) {
Logger.i(TAG, "Already started.") Logger.i(TAG, "Already started.")
@@ -216,6 +223,11 @@ class StateSync {
return _sessions.values.toList() return _sessions.values.toList()
}; };
} }
fun getAuthorizedSessions(): List<SyncSession> {
return synchronized(_sessions) {
return _sessions.values.filter { it.isAuthorized }.toList()
};
}
fun getSyncSessionData(key: String): SyncSessionData { fun getSyncSessionData(key: String): SyncSessionData {
return _syncSessionData.get(key) ?: SyncSessionData(key); return _syncSessionData.get(key) ?: SyncSessionData(key);
@@ -294,12 +306,22 @@ class StateSync {
synchronized(_sessions) { synchronized(_sessions) {
session = _sessions[s.remotePublicKey] session = _sessions[s.remotePublicKey]
if (session == null) { if (session == null) {
val remoteDeviceName = synchronized(_nameStorage) {
_nameStorage.get(remotePublicKey)
}
session = SyncSession(remotePublicKey, onAuthorized = { it, isNewlyAuthorized, isNewSession -> session = SyncSession(remotePublicKey, onAuthorized = { it, isNewlyAuthorized, isNewSession ->
if (!isNewSession) { if (!isNewSession) {
return@SyncSession return@SyncSession
} }
Logger.i(TAG, "${s.remotePublicKey} authorized") it.remoteDeviceName?.let { remoteDeviceName ->
synchronized(_nameStorage) {
_nameStorage.setAndSave(remotePublicKey, remoteDeviceName)
}
}
Logger.i(TAG, "${s.remotePublicKey} authorized (name: ${it.displayName})")
synchronized(_lastAddressStorage) { synchronized(_lastAddressStorage) {
_lastAddressStorage.setAndSave(remotePublicKey, s.remoteAddress) _lastAddressStorage.setAndSave(remotePublicKey, s.remoteAddress)
} }
@@ -330,7 +352,7 @@ class StateSync {
deviceRemoved.emit(it.remotePublicKey) deviceRemoved.emit(it.remotePublicKey)
}) }, remoteDeviceName)
_sessions[remotePublicKey] = session!! _sessions[remotePublicKey] = session!!
} }
session!!.addSocketSession(s) session!!.addSocketSession(s)
@@ -349,8 +371,12 @@ class StateSync {
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
UIDialogs.showConfirmationDialog(activity, "Allow connection from ${remotePublicKey}?", action = { UIDialogs.showConfirmationDialog(activity, "Allow connection from ${remotePublicKey}?", action = {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
session!!.authorize(s) try {
Logger.i(TAG, "Connection authorized for ${remotePublicKey} by confirmation") session!!.authorize(s)
Logger.i(TAG, "Connection authorized for $remotePublicKey by confirmation")
} catch (e: Throwable) {
Logger.e(TAG, "Failed to send authorize", e)
}
} }
}, cancelAction = { }, cancelAction = {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
@@ -404,11 +430,9 @@ class StateSync {
broadcast(opcode, subOpcode, data.toByteArray(Charsets.UTF_8)); broadcast(opcode, subOpcode, data.toByteArray(Charsets.UTF_8));
} }
fun broadcast(opcode: UByte, subOpcode: UByte, data: ByteArray) { fun broadcast(opcode: UByte, subOpcode: UByte, data: ByteArray) {
for(session in getSessions()) { for(session in getAuthorizedSessions()) {
try { try {
if (session.isAuthorized && session.connected) { session.send(opcode, subOpcode, data);
session.send(opcode, subOpcode, data);
}
} }
catch(ex: Exception) { catch(ex: Exception) {
Logger.w(TAG, "Failed to broadcast (opcode = ${opcode}, subOpcode = ${subOpcode}) to ${session.remotePublicKey}: ${ex.message}}", ex); Logger.w(TAG, "Failed to broadcast (opcode = ${opcode}, subOpcode = ${subOpcode}) to ${session.remotePublicKey}: ${ex.message}}", ex);
@@ -450,23 +474,18 @@ class StateSync {
return session return session
} }
fun hasAtLeastOneDevice(): Boolean {
synchronized(_authorizedDevices) {
return _authorizedDevices.values.isNotEmpty()
}
}
fun hasAtLeastOneOnlineDevice(): Boolean {
synchronized(_sessions) {
return _sessions.any{ it.value.connected && it.value.isAuthorized };
}
}
fun getAll(): List<String> { fun getAll(): List<String> {
synchronized(_authorizedDevices) { synchronized(_authorizedDevices) {
return _authorizedDevices.values.toList() return _authorizedDevices.values.toList()
} }
} }
fun getCachedName(publicKey: String): String? {
return synchronized(_nameStorage) {
_nameStorage.get(publicKey)
}
}
suspend fun delete(publicKey: String) { suspend fun delete(publicKey: String) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
@@ -189,9 +189,9 @@ class StateUpdate {
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to check for updates.", e); Logger.w(TAG, "Failed to check for updates.", e);
android.util.Log.e(TAG, "Failed to check for updates.", e);
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to check for updates"); UIDialogs.toast(context, "Failed to check for updates\n" + e.message);
} }
} }
} }
@@ -1,31 +0,0 @@
package com.futo.platformplayer.stores
import com.futo.platformplayer.polycentric.PolycentricCache
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@kotlinx.serialization.Serializable
class CachedPolycentricProfileStorage : FragmentedStorageFileJson() {
var map: HashMap<String, PolycentricCache.CachedPolycentricProfile> = hashMapOf();
override fun encode(): String {
val encoded = Json.encodeToString(this);
return encoded;
}
fun get(key: String) : PolycentricCache.CachedPolycentricProfile? {
return map[key];
}
fun setAndSave(key: String, value: PolycentricCache.CachedPolycentricProfile) : PolycentricCache.CachedPolycentricProfile {
map[key] = value;
save();
return value;
}
fun setAndSaveBlocking(key: String, value: PolycentricCache.CachedPolycentricProfile) : PolycentricCache.CachedPolycentricProfile {
map[key] = value;
saveBlocking();
return value;
}
}
@@ -33,6 +33,9 @@ class ManagedStore<T>{
val className: String? get() = _class.classifier?.assume<KClass<*>>()?.simpleName; val className: String? get() = _class.classifier?.assume<KClass<*>>()?.simpleName;
private var _onModificationCreate: ((T) -> Unit)? = null;
private var _onModificationDelete: ((T) -> Unit)? = null;
val name: String; val name: String;
constructor(name: String, dir: File, clazz: KType, serializer: StoreSerializer<T>, niceName: String? = null) { constructor(name: String, dir: File, clazz: KType, serializer: StoreSerializer<T>, niceName: String? = null) {
@@ -62,6 +65,12 @@ class ManagedStore<T>{
return this; return this;
} }
fun withOnModified(created: (T)->Unit, deleted: (T)->Unit): ManagedStore<T> {
_onModificationCreate = created;
_onModificationDelete = deleted;
return this;
}
fun load(): ManagedStore<T> { fun load(): ManagedStore<T> {
synchronized(_files) { synchronized(_files) {
_files.clear(); _files.clear();
@@ -265,6 +274,7 @@ class ManagedStore<T>{
file = saveNew(obj); file = saveNew(obj);
if(_reconstructStore != null && (_reconstructStore!!.backupOnCreate || withReconstruction)) if(_reconstructStore != null && (_reconstructStore!!.backupOnCreate || withReconstruction))
saveReconstruction(file, obj); saveReconstruction(file, obj);
_onModificationCreate?.invoke(obj)
} }
} }
} }
@@ -300,6 +310,7 @@ class ManagedStore<T>{
_files.remove(item); _files.remove(item);
Logger.v(TAG, "Deleting file ${logName(file.id)}"); Logger.v(TAG, "Deleting file ${logName(file.id)}");
file.delete(); file.delete();
_onModificationDelete?.invoke(item)
} }
} }
} }
@@ -6,12 +6,10 @@ import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.smartMerge import com.futo.platformplayer.smartMerge
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StateSubscriptionGroups import com.futo.platformplayer.states.StateSubscriptionGroups
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
@@ -30,6 +28,7 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.time.Instant import java.time.Instant
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -53,6 +52,9 @@ class SyncSession : IAuthorizable {
private val _id = UUID.randomUUID() private val _id = UUID.randomUUID()
private var _remoteId: UUID? = null private var _remoteId: UUID? = null
private var _lastAuthorizedRemoteId: UUID? = null private var _lastAuthorizedRemoteId: UUID? = null
var remoteDeviceName: String? = null
private set
val displayName: String get() = remoteDeviceName ?: remotePublicKey
var connected: Boolean = false var connected: Boolean = false
private set(v) { private set(v) {
@@ -62,7 +64,7 @@ class SyncSession : IAuthorizable {
} }
} }
constructor(remotePublicKey: String, onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit, onUnauthorized: (session: SyncSession) -> Unit, onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit, onClose: (session: SyncSession) -> Unit) { constructor(remotePublicKey: String, onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit, onUnauthorized: (session: SyncSession) -> Unit, onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit, onClose: (session: SyncSession) -> Unit, remoteDeviceName: String?) {
this.remotePublicKey = remotePublicKey this.remotePublicKey = remotePublicKey
_onAuthorized = onAuthorized _onAuthorized = onAuthorized
_onUnauthorized = onUnauthorized _onUnauthorized = onUnauthorized
@@ -85,7 +87,20 @@ class SyncSession : IAuthorizable {
fun authorize(socketSession: SyncSocketSession) { fun authorize(socketSession: SyncSocketSession) {
Logger.i(TAG, "Sent AUTHORIZED with session id $_id") Logger.i(TAG, "Sent AUTHORIZED with session id $_id")
socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(_id.toString().toByteArray()))
if (socketSession.remoteVersion >= 3) {
val idStringBytes = _id.toString().toByteArray()
val nameBytes = "${android.os.Build.MANUFACTURER}-${android.os.Build.MODEL}".toByteArray()
val buffer = ByteArray(1 + idStringBytes.size + 1 + nameBytes.size)
socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN).apply {
put(idStringBytes.size.toByte())
put(idStringBytes)
put(nameBytes.size.toByte())
put(nameBytes)
}.apply { flip() })
} else {
socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(_id.toString().toByteArray()))
}
_authorized = true _authorized = true
checkAuthorized() checkAuthorized()
} }
@@ -138,15 +153,37 @@ class SyncSession : IAuthorizable {
when (opcode) { when (opcode) {
Opcode.NOTIFY_AUTHORIZED.value -> { Opcode.NOTIFY_AUTHORIZED.value -> {
val str = data.toUtf8String() if (socketSession.remoteVersion >= 3) {
_remoteId = if (data.remaining() >= 0) UUID.fromString(str) else UUID.fromString("00000000-0000-0000-0000-000000000000") val idByteCount = data.get().toInt()
if (idByteCount > 64)
throw Exception("Id should always be smaller than 64 bytes")
val idBytes = ByteArray(idByteCount)
data.get(idBytes)
val nameByteCount = data.get().toInt()
if (nameByteCount > 64)
throw Exception("Name should always be smaller than 64 bytes")
val nameBytes = ByteArray(nameByteCount)
data.get(nameBytes)
_remoteId = UUID.fromString(idBytes.toString(Charsets.UTF_8))
remoteDeviceName = nameBytes.toString(Charsets.UTF_8)
} else {
val str = data.toUtf8String()
_remoteId = if (data.remaining() >= 0) UUID.fromString(str) else UUID.fromString("00000000-0000-0000-0000-000000000000")
remoteDeviceName = null
}
_remoteAuthorized = true _remoteAuthorized = true
Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId") Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId (device name: '${remoteDeviceName ?: "not set"}')")
checkAuthorized() checkAuthorized()
return return
} }
Opcode.NOTIFY_UNAUTHORIZED.value -> { Opcode.NOTIFY_UNAUTHORIZED.value -> {
_remoteId = null _remoteId = null
remoteDeviceName = null
_lastAuthorizedRemoteId = null _lastAuthorizedRemoteId = null
_remoteAuthorized = false _remoteAuthorized = false
_onUnauthorized(this) _onUnauthorized(this)
@@ -398,7 +435,6 @@ class SyncSession : IAuthorizable {
} }
} }
inline fun <reified T> sendJsonData(subOpcode: UByte, data: T) { inline fun <reified T> sendJsonData(subOpcode: UByte, data: T) {
send(Opcode.DATA.value, subOpcode, Json.encodeToString<T>(data)); send(Opcode.DATA.value, subOpcode, Json.encodeToString<T>(data));
} }
@@ -409,12 +445,29 @@ class SyncSession : IAuthorizable {
send(opcode, subOpcode, data.toByteArray(Charsets.UTF_8)); send(opcode, subOpcode, data.toByteArray(Charsets.UTF_8));
} }
fun send(opcode: UByte, subOpcode: UByte, data: ByteArray) { fun send(opcode: UByte, subOpcode: UByte, data: ByteArray) {
val sock = _socketSessions.firstOrNull(); val socketSessions = synchronized(_socketSessions) {
if(sock != null){ _socketSessions.toList()
sock.send(opcode, subOpcode, ByteBuffer.wrap(data)); }
if (socketSessions.isEmpty()) {
Logger.v(TAG, "Packet was not sent (opcode = ${opcode}, subOpcode = ${subOpcode}) due to no connected sockets")
return
}
var sent = false
for (socketSession in socketSessions) {
try {
socketSession.send(opcode, subOpcode, ByteBuffer.wrap(data))
sent = true
break
} catch (e: Throwable) {
Logger.w(TAG, "Packet failed to send (opcode = ${opcode}, subOpcode = ${subOpcode})", e)
}
}
if (!sent) {
throw Exception("Packet was not sent (opcode = ${opcode}, subOpcode = ${subOpcode}) due to send errors and no remaining candidates")
} }
else
throw IllegalStateException("Session has no active sockets");
} }
private companion object { private companion object {
@@ -46,6 +46,8 @@ class SyncSocketSession {
val localPublicKey: String get() = _localPublicKey val localPublicKey: String get() = _localPublicKey
private val _onData: (session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit private val _onData: (session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit
var authorizable: IAuthorizable? = null var authorizable: IAuthorizable? = null
var remoteVersion: Int = -1
private set
val remoteAddress: String val remoteAddress: String
@@ -162,11 +164,12 @@ class SyncSocketSession {
} }
private fun performVersionCheck() { private fun performVersionCheck() {
val CURRENT_VERSION = 2 val CURRENT_VERSION = 3
val MINIMUM_VERSION = 2
_outputStream.writeInt(CURRENT_VERSION) _outputStream.writeInt(CURRENT_VERSION)
val version = _inputStream.readInt() remoteVersion = _inputStream.readInt()
Logger.i(TAG, "performVersionCheck (version = $version)") Logger.i(TAG, "performVersionCheck (version = $remoteVersion)")
if (version != CURRENT_VERSION) if (remoteVersion < MINIMUM_VERSION)
throw Exception("Invalid version") throw Exception("Invalid version")
} }
@@ -300,6 +303,8 @@ class SyncSocketSession {
} }
private fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { private fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
Logger.i(TAG, "Handle packet (opcode = ${opcode}, subOpcode = ${subOpcode})")
when (opcode) { when (opcode) {
Opcode.PING.value -> { Opcode.PING.value -> {
send(Opcode.PONG.value) send(Opcode.PONG.value)
@@ -16,11 +16,11 @@ import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.adapters.viewholders.StoreItemViewHolder import com.futo.platformplayer.views.adapters.viewholders.StoreItemViewHolder
import com.futo.platformplayer.views.platform.PlatformIndicator import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.polycentric.core.PolycentricProfile
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -125,8 +125,7 @@ class MonetizationView : LinearLayout {
} }
} }
fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?) { fun setPolycentricProfile(profile: PolycentricProfile?) {
val profile = cachedPolycentricProfile?.profile;
if (profile != null) { if (profile != null) {
if (profile.systemState.store.isNotEmpty()) { if (profile.systemState.store.isNotEmpty()) {
_buttonStore.visibility = View.VISIBLE; _buttonStore.visibility = View.VISIBLE;
@@ -14,10 +14,10 @@ import androidx.core.view.isVisible
import androidx.core.view.size import androidx.core.view.size
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.imageview.ShapeableImageView
@@ -55,7 +55,7 @@ class ToastView : LinearLayout {
translationY = 20.dp(context.resources).toFloat(); translationY = 20.dp(context.resources).toFloat();
animate() animate()
.alpha(1f) .alpha(1f)
.setDuration(700) .setDuration(300)
.translationY(0f) .translationY(0f)
.start(); .start();
} }
@@ -0,0 +1,74 @@
package com.futo.platformplayer.views
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.LinearLayout
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
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.channels.SerializedChannel
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.states.StateSubscriptionGroups
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.others.ToggleTagView
import com.futo.platformplayer.views.adapters.viewholders.SubscriptionBarViewHolder
import com.futo.platformplayer.views.adapters.viewholders.SubscriptionGroupBarViewHolder
import com.futo.platformplayer.views.subscriptions.SubscriptionExploreButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ToggleBar : LinearLayout {
private val _tagsContainer: LinearLayout;
override fun onAttachedToWindow() {
super.onAttachedToWindow();
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow();
StateSubscriptionGroups.instance.onGroupsChanged.remove(this);
}
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_toggle_bar, this);
_tagsContainer = findViewById(R.id.container_tags);
}
fun setToggles(vararg buttons: Toggle) {
_tagsContainer.removeAllViews();
for(button in buttons) {
_tagsContainer.addView(ToggleTagView(context).apply {
this.setInfo(button.name, button.isActive);
this.onClick.subscribe { button.action(it); };
});
}
}
class Toggle {
val name: String;
val icon: Int;
val action: (Boolean)->Unit;
val isActive: Boolean;
constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) {
this.name = name;
this.icon = icon;
this.action = action;
this.isActive = isActive;
}
constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) {
this.name = name;
this.icon = 0;
this.action = action;
this.isActive = isActive;
}
}
}
@@ -5,6 +5,7 @@ import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel 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.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
@@ -16,12 +17,12 @@ import com.futo.platformplayer.fragment.channel.tab.ChannelListFragment
import com.futo.platformplayer.fragment.channel.tab.ChannelMonetizationFragment import com.futo.platformplayer.fragment.channel.tab.ChannelMonetizationFragment
import com.futo.platformplayer.fragment.channel.tab.ChannelPlaylistsFragment import com.futo.platformplayer.fragment.channel.tab.ChannelPlaylistsFragment
import com.futo.platformplayer.fragment.channel.tab.IChannelTabFragment import com.futo.platformplayer.fragment.channel.tab.IChannelTabFragment
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.polycentric.core.PolycentricProfile
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
enum class ChannelTab { enum class ChannelTab {
VIDEOS, CHANNELS, PLAYLISTS, SUPPORT, ABOUT VIDEOS, SHORTS, CHANNELS, PLAYLISTS, SUPPORT, ABOUT
} }
class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) : class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) :
@@ -91,6 +92,19 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
} }
} }
ChannelTab.SHORTS -> {
fragment = ChannelContentsFragment.newInstance(ResultCapabilities.TYPE_SHORTS).apply {
onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit)
onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit)
onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit)
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit)
onAddToClicked.subscribe(this@ChannelViewPagerAdapter.onAddToClicked::emit)
onAddToQueueClicked.subscribe(this@ChannelViewPagerAdapter.onAddToQueueClicked::emit)
onAddToWatchLaterClicked.subscribe(this@ChannelViewPagerAdapter.onAddToWatchLaterClicked::emit)
onLongPress.subscribe(this@ChannelViewPagerAdapter.onLongPress::emit)
}
}
ChannelTab.CHANNELS -> { ChannelTab.CHANNELS -> {
fragment = ChannelListFragment.newInstance() fragment = ChannelListFragment.newInstance()
.apply { onClickChannel.subscribe(onChannelClicked::emit) } .apply { onClickChannel.subscribe(onChannelClicked::emit) }
@@ -18,8 +18,6 @@ import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.api.media.models.ratings.RatingLikes import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.fixHtmlLinks import com.futo.platformplayer.fixHtmlLinks
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
@@ -29,6 +27,7 @@ import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.others.CreatorThumbnail import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.pills.PillButton import com.futo.platformplayer.views.pills.PillButton
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.Opinion import com.futo.polycentric.core.Opinion
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -81,24 +80,18 @@ class CommentViewHolder : ViewHolder {
throw Exception("Not implemented for non polycentric comments") throw Exception("Not implemented for non polycentric comments")
} }
if (args.hasLiked) { val newOpinion: Opinion = if (args.hasLiked) {
args.processHandle.opinion(c.reference, Opinion.like); Opinion.like
} else if (args.hasDisliked) { } else if (args.hasDisliked) {
args.processHandle.opinion(c.reference, Opinion.dislike); Opinion.dislike
} else { } else {
args.processHandle.opinion(c.reference, Opinion.neutral); Opinion.neutral
} }
_layoutComment.alpha = if (args.dislikes > 2 && args.dislikes.toFloat() / (args.likes + args.dislikes).toFloat() >= 0.7f) 0.5f else 1.0f; _layoutComment.alpha = if (args.dislikes > 2 && args.dislikes.toFloat() / (args.likes + args.dislikes).toFloat() >= 0.7f) 0.5f else 1.0f;
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { ApiMethods.setOpinion(args.processHandle, c.reference, newOpinion)
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers.", e)
}
} }
StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked) StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked)
@@ -16,7 +16,6 @@ import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.fixHtmlLinks import com.futo.platformplayer.fixHtmlLinks
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
@@ -26,6 +25,7 @@ import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.pills.PillButton import com.futo.platformplayer.views.pills.PillButton
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
import com.futo.polycentric.core.Opinion import com.futo.polycentric.core.Opinion
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.IdentityHashMap import java.util.IdentityHashMap
@@ -16,7 +16,6 @@ import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.others.CreatorThumbnail import com.futo.platformplayer.views.others.CreatorThumbnail
@@ -29,21 +28,12 @@ open class PlaylistView : LinearLayout {
protected val _imageThumbnail: ImageView protected val _imageThumbnail: ImageView
protected val _imageChannel: ImageView? protected val _imageChannel: ImageView?
protected val _creatorThumbnail: CreatorThumbnail? protected val _creatorThumbnail: CreatorThumbnail?
protected val _imageNeopassChannel: ImageView?;
protected val _platformIndicator: PlatformIndicator; protected val _platformIndicator: PlatformIndicator;
protected val _textPlaylistName: TextView protected val _textPlaylistName: TextView
protected val _textVideoCount: TextView protected val _textVideoCount: TextView
protected val _textVideoCountLabel: TextView; protected val _textVideoCountLabel: TextView;
protected val _textPlaylistItems: TextView protected val _textPlaylistItems: TextView
protected val _textChannelName: TextView protected val _textChannelName: TextView
protected var _neopassAnimator: ObjectAnimator? = null;
private val _taskLoadValidClaims = TaskHandler<PlatformID, PolycentricCache.CachedOwnedClaims>(StateApp.instance.scopeGetter,
{ PolycentricCache.instance.getValidClaimsAsync(it).await() })
.success { it -> updateClaimsLayout(it, animate = true) }
.exception<Throwable> {
Logger.w(TAG, "Failed to load claims.", it);
};
val onPlaylistClicked = Event1<IPlatformPlaylist>(); val onPlaylistClicked = Event1<IPlatformPlaylist>();
val onChannelClicked = Event1<PlatformAuthorLink>(); val onChannelClicked = Event1<PlatformAuthorLink>();
@@ -66,7 +56,6 @@ open class PlaylistView : LinearLayout {
_textVideoCountLabel = findViewById(R.id.text_video_count_label); _textVideoCountLabel = findViewById(R.id.text_video_count_label);
_textChannelName = findViewById(R.id.text_channel_name); _textChannelName = findViewById(R.id.text_channel_name);
_textPlaylistItems = findViewById(R.id.text_playlist_items); _textPlaylistItems = findViewById(R.id.text_playlist_items);
_imageNeopassChannel = findViewById(R.id.image_neopass_channel);
setOnClickListener { onOpenClicked() }; setOnClickListener { onOpenClicked() };
_imageChannel?.setOnClickListener { currentPlaylist?.let { onChannelClicked.emit(it.author) } }; _imageChannel?.setOnClickListener { currentPlaylist?.let { onChannelClicked.emit(it.author) } };
@@ -88,20 +77,6 @@ open class PlaylistView : LinearLayout {
open fun bind(content: IPlatformContent) { open fun bind(content: IPlatformContent) {
_taskLoadValidClaims.cancel();
if (content.author.id.claimType > 0) {
val cachedClaims = PolycentricCache.instance.getCachedValidClaims(content.author.id);
if (cachedClaims != null) {
updateClaimsLayout(cachedClaims, animate = false);
} else {
updateClaimsLayout(null, animate = false);
_taskLoadValidClaims.run(content.author.id);
}
} else {
updateClaimsLayout(null, animate = false);
}
isClickable = true; isClickable = true;
_imageChannel?.let { _imageChannel?.let {
@@ -155,25 +130,6 @@ open class PlaylistView : LinearLayout {
} }
} }
private fun updateClaimsLayout(claims: PolycentricCache.CachedOwnedClaims?, animate: Boolean) {
_neopassAnimator?.cancel();
_neopassAnimator = null;
val firstClaim = claims?.ownedClaims?.firstOrNull();
val harborAvailable = firstClaim != null
if (harborAvailable) {
_imageNeopassChannel?.visibility = View.VISIBLE
if (animate) {
_neopassAnimator = ObjectAnimator.ofFloat(_imageNeopassChannel, "alpha", 0.0f, 1.0f).setDuration(500)
_neopassAnimator?.start()
}
} else {
_imageNeopassChannel?.visibility = View.GONE
}
_creatorThumbnail?.setHarborAvailable(harborAvailable, animate, firstClaim?.system?.toProto())
}
companion object { companion object {
private val TAG = "VideoPreviewViewHolder" private val TAG = "VideoPreviewViewHolder"
} }

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