Compare commits

...

128 Commits

Author SHA1 Message Date
Kelvin 8384f227be Plugin refs 2025-04-30 20:02:09 +02:00
Kelvin 697b3bc5f5 SLD domain checking fix, download notification if on metered, check for unstarted downloads on opening ui, minor fixes/imrpovements 2025-04-30 20:00:48 +02:00
Koen J 9e2041521e Made the disconnect button easier to click on casting connected dialog. 2025-04-29 15:27:24 +02:00
Koen J ee7b89ec6e Added new casting dialog. 2025-04-29 15:22:06 +02:00
Koen J 5b143bdc76 Switch to NsdManager. 2025-04-29 08:39:05 +02:00
Koen J d9d00e452e Explicitly set network interface in joinGroup. 2025-04-28 16:59:11 +02:00
Koen J 14500e281c Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-04-28 13:59:59 +02:00
Koen J c4623c80ff Implemented app id and updated unit tests. 2025-04-28 13:59:50 +02:00
Kelvin 9e17dce9a9 Fix edgecase where activity killed before 5s after opening 2025-04-25 17:40:45 +02:00
Koen J daa91986ef Add search type selector to suggestions fragment. 2025-04-22 13:40:05 +02:00
Koen J 63761cfc9a Simplified all searches to use ContentSearchResultsFragment. 2025-04-22 13:08:23 +02:00
Koen J d10026acd1 Added ping loop. 2025-04-21 13:32:58 +02:00
Koen J 9347351c37 Fixed issue where it would continuously try to connect over relay. 2025-04-15 09:39:35 +02:00
Koen J 0ef1f2d40f Added LinkType to Channel. 2025-04-14 15:19:16 +02:00
Koen J b460f9915d Added settings for enabling/disabling remote sync features. Fixed device pairing success showing too early. 2025-04-14 14:41:47 +02:00
Koen J 4e195dfbc3 Rename to direct and relayed. 2025-04-14 10:38:42 +02:00
Koen 3c7f7bfca7 Merge branch 'remote-sync' into 'master'
Implemented remote sync.

See merge request videostreaming/grayjay!93
2025-04-11 14:31:47 +00:00
Koen 05230971b3 Implemented remote sync. 2025-04-11 14:31:47 +00:00
Kelvin K dccdf72c73 Message change 2025-04-09 23:35:44 +02:00
Kelvin K ca15983a72 Casting message, caching creator images 2025-04-09 23:26:35 +02:00
Kelvin K 4b6a2c9829 Keyboard hide on search end 2025-04-09 21:02:19 +02:00
Kelvin K 1755d03a6b Fcast clearer connection/reconnection overlay, disable ipv6 by default 2025-04-09 00:56:49 +02:00
Kelvin K 869b1fc15e Fix pager for landscape 2025-04-08 00:34:52 +02:00
Kelvin K ce2a2f8582 submods 2025-04-07 23:32:57 +02:00
Kelvin K 7b355139fb Subscription persistence fixes, home toggle fixes, subs exchange gzip, etc 2025-04-07 23:31:00 +02:00
Kelvin K b14518edb1 Home filter fixes, persistent sorts, subs exchange fixes, playlist video options 2025-04-05 01:02:50 +02:00
Kelvin K 7d64003d1c Feed filter loading improved, home filters support, various peripheral stuff 2025-04-04 00:37:26 +02:00
Kelvin K 0a59e04f19 Fix ui offset issue when opening video through search url 2025-04-02 23:40:37 +02:00
Kelvin b57abb646f Merge branch 'subs-exchange' into 'master'
Experimental Subs Exchange

See merge request videostreaming/grayjay!91
2025-04-02 21:12:07 +00:00
Kelvin K dd6bde97a9 Playlists sort and search support, Playlist search support, wip local playback, other fixes 2025-04-02 22:53:54 +02:00
Kelvin K b545545712 Remove dep 2025-04-01 01:01:45 +02:00
Kelvin K c1993ffa03 SubsExchange fixes 2025-04-01 00:56:24 +02:00
Kelvin K 7f7ebafa46 Resume on playback error instead of reseting, dont error on empty author url, subs exchange fixes 2025-03-31 20:02:49 +02:00
Kelvin K b652597924 chapters ui on text press 2025-03-28 21:40:17 +01:00
Koen 258fe77928 Merge branch 'add-plugin-tedtalks' into 'master'
add ted talks plugin

See merge request videostreaming/grayjay!90
2025-03-28 19:42:30 +00:00
Stefan 5a9fcd6fab add ted talks plugin 2025-03-28 19:13:55 +00:00
Kelvin K 3c05521a5b Chapter Overlay 2025-03-27 23:25:13 +01:00
Kelvin K 034b8b15ae WIP SubsExchange 2025-03-26 23:28:32 +01:00
Kelvin K 7bd687331b AddSource by url support, submods 2025-03-24 20:33:17 +01:00
Kelvin K 54d58df4b6 Sync watch later on initial connection, Original audio boolean support, priority audio support, setting to prefer original audio 2025-03-21 02:23:55 +01:00
Kai DeLorenzo 9165a9f7cb Add : support for login button selector 2025-03-17 23:24:15 +00:00
Kelvin b556d1e81d Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-03-10 22:12:27 +01:00
Kelvin 7c25678211 Subgroup sub image url, ImageVariable default error icon on fail to load 2025-03-10 22:12:17 +01:00
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
266 changed files with 7777 additions and 4859 deletions
+29 -15
View File
@@ -1,19 +1,19 @@
name: Bug Report
description: Let us know about an unexpected error, a crash, or an incorrect behavior.
labels: ["bug", "new"]
labels: ["Bug"]
body:
- type: markdown
attributes:
value: |
# Thank you for taking the time to fill out this bug report.
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application
For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
## Filing a bug report
To fix your issues faster, we need clear reproduction cases - ideally allowing us to make it happen locally.
To fix your issues faster, we need clear reproduction cases - ideally allowing us to make it happen locally.
* Please include all needed context. For example, Device, OS, Application, your Grayjay Configurations and Plugin versioning info.
* if you've found out a particular series of UI interactions can introduce buggy behavior, please label those steps 1-n with markdown
@@ -41,18 +41,21 @@ body:
label: What plugins are you seeing the problem on?
multiple: true
options:
- All
- Youtube
- BiliBili (CN)
- Twitch
- Odysee
- Rumble
- Kick
- PeerTube
- Patreon
- Nebula
- SoundCloud
- Other
- "All"
- "Youtube"
- "Odysee"
- "Rumble"
- "Kick"
- "Twitch"
- "PeerTube"
- "Patreon"
- "Nebula"
- "BiliBili (CN)"
- "Bitchute"
- "SoundCloud"
- "Dailymotion"
- "Apple Podcasts"
- "Other"
validations:
required: true
@@ -72,6 +75,17 @@ body:
- label: While logged out
- label: N/A
- type: dropdown
id: vpn
attributes:
label: Are you using a VPN?
multiple: false
options:
- "No"
- "Yes"
validations:
required: true
- type: textarea
id: logs
attributes:
@@ -1,13 +1,13 @@
name: Documentation Issue
description: Report an issue or suggest a change in the documentation.
labels: ["documentation", "new"]
labels: ["Documentation"]
body:
- type: markdown
attributes:
value: |
# Thank you for opening a documentation change request.
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application. 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.
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
description: Suggest a new feature or other enhancement.
labels: ["enhancement", "new"]
labels: ["Enhancement"]
body:
- type: markdown
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
[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)
- type: textarea
@@ -55,4 +53,4 @@ body:
attributes:
value: |
**Note:** If the submit button is disabled and you have filled out all required fields, please check that you did not forget a **Title** for the issue.
-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 }}
+12
View File
@@ -82,3 +82,15 @@
[submodule "app/src/stable/assets/sources/dailymotion"]
path = app/src/stable/assets/sources/dailymotion
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
[submodule "app/src/stable/assets/sources/tedtalks"]
path = app/src/stable/assets/sources/tedtalks
url = ../plugins/tedtalks.git
[submodule "app/src/unstable/assets/sources/tedtalks"]
path = app/src/unstable/assets/sources/tedtalks
url = ../plugins/tedtalks.git
+1 -1
View File
@@ -197,7 +197,7 @@ dependencies {
implementation 'org.jsoup:jsoup:1.15.3'
implementation 'com.google.android.flexbox:flexbox:3.0.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 'com.github.dhaval2404:imagepicker:2.1'
implementation 'com.google.zxing:core:3.4.1'
@@ -0,0 +1,338 @@
package com.futo.platformplayer
import com.futo.platformplayer.noise.protocol.Noise
import com.futo.platformplayer.sync.internal.*
import kotlinx.coroutines.*
import kotlinx.coroutines.selects.select
import org.junit.Assert.*
import org.junit.Test
import java.net.Socket
import java.nio.ByteBuffer
import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class SyncServerTests {
//private val relayHost = "relay.grayjay.app"
//private val relayKey = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw="
private val relayKey = "XlUaSpIlRaCg0TGzZ7JYmPupgUHDqTZXUUBco2K7ejw="
private val relayHost = "192.168.1.138"
private val relayPort = 9000
/** Creates a client connected to the live relay server. */
private suspend fun createClient(
onHandshakeComplete: ((SyncSocketSession) -> Unit)? = null,
onData: ((SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit)? = null,
onNewChannel: ((SyncSocketSession, ChannelRelayed) -> Unit)? = null,
isHandshakeAllowed: ((LinkType, SyncSocketSession, String, String?, UInt) -> Boolean)? = null,
onException: ((Throwable) -> Unit)? = null
): SyncSocketSession = withContext(Dispatchers.IO) {
val p = Noise.createDH("25519")
p.generateKeyPair()
val socket = Socket(relayHost, relayPort)
val inputStream = LittleEndianDataInputStream(socket.getInputStream())
val outputStream = LittleEndianDataOutputStream(socket.getOutputStream())
val tcs = CompletableDeferred<Boolean>()
val socketSession = SyncSocketSession(
relayHost,
p,
inputStream,
outputStream,
onClose = { socket.close() },
onHandshakeComplete = { s ->
onHandshakeComplete?.invoke(s)
tcs.complete(true)
},
onData = onData ?: { _, _, _, _ -> },
onNewChannel = onNewChannel ?: { _, _ -> },
isHandshakeAllowed = isHandshakeAllowed ?: { _, _, _, _, _ -> true }
)
socketSession.authorizable = AlwaysAuthorized()
try {
socketSession.startAsInitiator(relayKey)
} catch (e: Throwable) {
onException?.invoke(e)
}
withTimeout(5000.milliseconds) { tcs.await() }
return@withContext socketSession
}
@Test
fun multipleClientsHandshake_Success() = runBlocking {
val client1 = createClient()
val client2 = createClient()
assertNotNull(client1.remotePublicKey, "Client 1 handshake failed")
assertNotNull(client2.remotePublicKey, "Client 2 handshake failed")
client1.stop()
client2.stop()
}
@Test
fun publishAndRequestConnectionInfo_Authorized_Success() = runBlocking {
val clientA = createClient()
val clientB = createClient()
val clientC = createClient()
clientA.publishConnectionInformation(arrayOf(clientB.localPublicKey), 12345, true, true, true, true)
delay(100.milliseconds)
val infoB = clientB.requestConnectionInfo(clientA.localPublicKey)
val infoC = clientC.requestConnectionInfo(clientA.localPublicKey)
assertNotNull("Client B should receive connection info", infoB)
assertEquals(12345.toUShort(), infoB!!.port)
assertNull("Client C should not receive connection info (unauthorized)", infoC)
clientA.stop()
clientB.stop()
clientC.stop()
}
@Test
fun relayedTransport_Bidirectional_Success() = runBlocking {
val tcsA = CompletableDeferred<ChannelRelayed>()
val tcsB = CompletableDeferred<ChannelRelayed>()
val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
channelA.authorizable = AlwaysAuthorized()
val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
channelB.authorizable = AlwaysAuthorized()
channelTask.await()
val tcsDataB = CompletableDeferred<ByteArray>()
channelB.setDataHandler { _, _, o, so, d ->
val b = ByteArray(d.remaining())
d.get(b)
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
}
channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(1, 2, 3)))
val tcsDataA = CompletableDeferred<ByteArray>()
channelA.setDataHandler { _, _, o, so, d ->
val b = ByteArray(d.remaining())
d.get(b)
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataA.complete(b)
}
channelB.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(4, 5, 6)))
val receivedB = withTimeout(5000.milliseconds) { tcsDataB.await() }
val receivedA = withTimeout(5000.milliseconds) { tcsDataA.await() }
assertArrayEquals(byteArrayOf(1, 2, 3), receivedB)
assertArrayEquals(byteArrayOf(4, 5, 6), receivedA)
clientA.stop()
clientB.stop()
}
@Test
fun relayedTransport_MaximumMessageSize_Success() = runBlocking {
val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE - 8 - 16 - 16
val maxSizeData = ByteArray(MAX_DATA_PER_PACKET).apply { Random.nextBytes(this) }
val tcsA = CompletableDeferred<ChannelRelayed>()
val tcsB = CompletableDeferred<ChannelRelayed>()
val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
channelA.authorizable = AlwaysAuthorized()
val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
channelB.authorizable = AlwaysAuthorized()
channelTask.await()
val tcsDataB = CompletableDeferred<ByteArray>()
channelB.setDataHandler { _, _, o, so, d ->
val b = ByteArray(d.remaining())
d.get(b)
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
}
channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxSizeData))
val receivedData = withTimeout(5000.milliseconds) { tcsDataB.await() }
assertArrayEquals(maxSizeData, receivedData)
clientA.stop()
clientB.stop()
}
@Test
fun publishAndGetRecord_Success() = runBlocking {
val clientA = createClient()
val clientB = createClient()
val clientC = createClient()
val data = byteArrayOf(1, 2, 3)
val success = clientA.publishRecords(listOf(clientB.localPublicKey), "testKey", data)
val recordB = clientB.getRecord(clientA.localPublicKey, "testKey")
val recordC = clientC.getRecord(clientA.localPublicKey, "testKey")
assertTrue(success)
assertNotNull(recordB)
assertArrayEquals(data, recordB!!.first)
assertNull("Unauthorized client should not access record", recordC)
clientA.stop()
clientB.stop()
clientC.stop()
}
@Test
fun getNonExistentRecord_ReturnsNull() = runBlocking {
val clientA = createClient()
val clientB = createClient()
val record = clientB.getRecord(clientA.localPublicKey, "nonExistentKey")
assertNull("Getting non-existent record should return null", record)
clientA.stop()
clientB.stop()
}
@Test
fun updateRecord_TimestampUpdated() = runBlocking {
val clientA = createClient()
val clientB = createClient()
val key = "updateKey"
val data1 = byteArrayOf(1)
val data2 = byteArrayOf(2)
clientA.publishRecords(listOf(clientB.localPublicKey), key, data1)
val record1 = clientB.getRecord(clientA.localPublicKey, key)
delay(1000.milliseconds)
clientA.publishRecords(listOf(clientB.localPublicKey), key, data2)
val record2 = clientB.getRecord(clientA.localPublicKey, key)
assertNotNull(record1)
assertNotNull(record2)
assertTrue(record2!!.second > record1!!.second)
assertArrayEquals(data2, record2.first)
clientA.stop()
clientB.stop()
}
@Test
fun deleteRecord_Success() = runBlocking {
val clientA = createClient()
val clientB = createClient()
val data = byteArrayOf(1, 2, 3)
clientA.publishRecords(listOf(clientB.localPublicKey), "toDelete", data)
val success = clientB.deleteRecords(clientA.localPublicKey, clientB.localPublicKey, listOf("toDelete"))
val record = clientB.getRecord(clientA.localPublicKey, "toDelete")
assertTrue(success)
assertNull(record)
clientA.stop()
clientB.stop()
}
@Test
fun listRecordKeys_Success() = runBlocking {
val clientA = createClient()
val clientB = createClient()
val keys = arrayOf("key1", "key2", "key3")
keys.forEach { key ->
clientA.publishRecords(listOf(clientB.localPublicKey), key, byteArrayOf(1))
}
val listedKeys = clientB.listRecordKeys(clientA.localPublicKey, clientB.localPublicKey)
assertArrayEquals(keys, listedKeys.map { it.first }.toTypedArray())
clientA.stop()
clientB.stop()
}
@Test
fun singleLargeMessageViaRelayedChannel_Success() = runBlocking {
val largeData = ByteArray(100000).apply { Random.nextBytes(this) }
val tcsA = CompletableDeferred<ChannelRelayed>()
val tcsB = CompletableDeferred<ChannelRelayed>()
val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
channelA.authorizable = AlwaysAuthorized()
val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
channelB.authorizable = AlwaysAuthorized()
channelTask.await()
val tcsDataB = CompletableDeferred<ByteArray>()
channelB.setDataHandler { _, _, o, so, d ->
val b = ByteArray(d.remaining())
d.get(b)
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
}
channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData))
val receivedData = withTimeout(10000.milliseconds) { tcsDataB.await() }
assertArrayEquals(largeData, receivedData)
clientA.stop()
clientB.stop()
}
@Test
fun publishAndGetLargeRecord_Success() = runBlocking {
val largeData = ByteArray(1000000).apply { Random.nextBytes(this) }
val clientA = createClient()
val clientB = createClient()
val success = clientA.publishRecords(listOf(clientB.localPublicKey), "largeRecord", largeData)
val record = clientB.getRecord(clientA.localPublicKey, "largeRecord")
assertTrue(success)
assertNotNull(record)
assertArrayEquals(largeData, record!!.first)
clientA.stop()
clientB.stop()
}
@Test
fun relayedTransport_WithValidAppId_Success() = runBlocking {
// Arrange: Set up clients
val allowedAppId = 1234u
val tcsB = CompletableDeferred<ChannelRelayed>()
// Client B requires appId 1234
val clientB = createClient(
onNewChannel = { _, c -> tcsB.complete(c) },
isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId }
)
val clientA = createClient()
// Act: Start relayed channel with valid appId
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey, appId = allowedAppId) }
val channelB = withTimeout(5.seconds) { tcsB.await() }
withTimeout(5.seconds) { channelTask.await() }
// Assert: Channel is established
assertNotNull("Channel should be created on target with valid appId", channelB)
// Clean up
clientA.stop()
clientB.stop()
}
@Test
fun relayedTransport_WithInvalidAppId_Fails() = runBlocking {
// Arrange: Set up clients
val allowedAppId = 1234u
val invalidAppId = 5678u
val tcsB = CompletableDeferred<ChannelRelayed>()
// Client B requires appId 1234
val clientB = createClient(
onNewChannel = { _, c -> tcsB.complete(c) },
isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId },
onException = { }
)
val clientA = createClient()
// Act & Assert: Attempt with invalid appId should fail
try {
withTimeout(5.seconds) {
clientA.startRelayedChannel(clientB.localPublicKey, appId = invalidAppId)
}
fail("Starting relayed channel with invalid appId should fail")
} catch (e: Throwable) {
// Expected: The channel creation should time out or fail
}
// Ensure no channel was created on client B
val completedTask = select {
tcsB.onAwait { "channel" }
async { delay(1.seconds); "timeout" }.onAwait { "timeout" }
}
assertEquals("No channel should be created with invalid appId", "timeout", completedTask)
// Clean up
clientA.stop()
clientB.stop()
}
}
class AlwaysAuthorized : IAuthorizable {
override val isAuthorized: Boolean get() = true
}
@@ -0,0 +1,512 @@
package com.futo.platformplayer
import com.futo.platformplayer.noise.protocol.DHState
import com.futo.platformplayer.noise.protocol.Noise
import com.futo.platformplayer.sync.internal.*
import kotlinx.coroutines.*
import org.junit.Assert.*
import org.junit.Test
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.nio.ByteBuffer
import kotlin.random.Random
import java.io.InputStream
import java.io.OutputStream
import kotlin.time.Duration.Companion.seconds
data class PipeStreams(
val initiatorInput: LittleEndianDataInputStream,
val initiatorOutput: LittleEndianDataOutputStream,
val responderInput: LittleEndianDataInputStream,
val responderOutput: LittleEndianDataOutputStream
)
typealias OnHandshakeComplete = (SyncSocketSession) -> Unit
typealias IsHandshakeAllowed = (LinkType, SyncSocketSession, String, String?, UInt) -> Boolean
typealias OnClose = (SyncSocketSession) -> Unit
typealias OnData = (SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit
class SyncSocketTests {
private fun createPipeStreams(): PipeStreams {
val initiatorOutput = PipedOutputStream()
val responderOutput = PipedOutputStream()
val responderInput = PipedInputStream(initiatorOutput)
val initiatorInput = PipedInputStream(responderOutput)
return PipeStreams(
LittleEndianDataInputStream(initiatorInput), LittleEndianDataOutputStream(initiatorOutput),
LittleEndianDataInputStream(responderInput), LittleEndianDataOutputStream(responderOutput)
)
}
fun generateKeyPair(): DHState {
val p = Noise.createDH("25519")
p.generateKeyPair()
return p
}
private fun createSessions(
initiatorInput: LittleEndianDataInputStream,
initiatorOutput: LittleEndianDataOutputStream,
responderInput: LittleEndianDataInputStream,
responderOutput: LittleEndianDataOutputStream,
initiatorKeyPair: DHState,
responderKeyPair: DHState,
onInitiatorHandshakeComplete: OnHandshakeComplete,
onResponderHandshakeComplete: OnHandshakeComplete,
onInitiatorClose: OnClose? = null,
onResponderClose: OnClose? = null,
onClose: OnClose? = null,
isHandshakeAllowed: IsHandshakeAllowed? = null,
onDataA: OnData? = null,
onDataB: OnData? = null
): Pair<SyncSocketSession, SyncSocketSession> {
val initiatorSession = SyncSocketSession(
"", initiatorKeyPair, initiatorInput, initiatorOutput,
onClose = {
onClose?.invoke(it)
onInitiatorClose?.invoke(it)
},
onHandshakeComplete = onInitiatorHandshakeComplete,
onData = onDataA,
isHandshakeAllowed = isHandshakeAllowed
)
val responderSession = SyncSocketSession(
"", responderKeyPair, responderInput, responderOutput,
onClose = {
onClose?.invoke(it)
onResponderClose?.invoke(it)
},
onHandshakeComplete = onResponderHandshakeComplete,
onData = onDataB,
isHandshakeAllowed = isHandshakeAllowed
)
return Pair(initiatorSession, responderSession)
}
@Test
fun handshake_WithValidPairingCode_Succeeds(): Unit = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val validPairingCode = "secret"
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
)
initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = validPairingCode)
responderSession.startAsResponder()
withTimeout(5.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
}
@Test
fun handshake_WithInvalidPairingCode_Fails() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val validPairingCode = "secret"
val invalidPairingCode = "wrong"
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val initiatorClosed = CompletableDeferred<Boolean>()
val responderClosed = CompletableDeferred<Boolean>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onInitiatorClose = {
initiatorClosed.complete(true)
},
onResponderClose = {
responderClosed.complete(true)
},
isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
)
initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = invalidPairingCode)
responderSession.startAsResponder()
withTimeout(100.seconds) {
initiatorClosed.await()
responderClosed.await()
}
assertFalse(handshakeInitiatorCompleted.isCompleted)
assertFalse(handshakeResponderCompleted.isCompleted)
}
@Test
fun handshake_WithoutPairingCodeWhenRequired_Fails() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val validPairingCode = "secret"
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val initiatorClosed = CompletableDeferred<Boolean>()
val responderClosed = CompletableDeferred<Boolean>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onInitiatorClose = {
initiatorClosed.complete(true)
},
onResponderClose = {
responderClosed.complete(true)
},
isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
)
initiatorSession.startAsInitiator(responderSession.localPublicKey) // No pairing code
responderSession.startAsResponder()
withTimeout(5.seconds) {
initiatorClosed.await()
responderClosed.await()
}
assertFalse(handshakeInitiatorCompleted.isCompleted)
assertFalse(handshakeResponderCompleted.isCompleted)
}
@Test
fun handshake_WithPairingCodeWhenNotRequired_Succeeds(): Unit = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val pairingCode = "unnecessary"
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
isHandshakeAllowed = { _, _, _, _, _ -> true } // Always allow
)
initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = pairingCode)
responderSession.startAsResponder()
withTimeout(10.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
}
@Test
fun sendAndReceive_SmallDataPacket_Succeeds() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onDataB = { _, opcode, subOpcode, data ->
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
val b = ByteArray(data.remaining())
data.get(b)
tcsDataReceived.complete(b)
}
}
)
initiatorSession.startAsInitiator(responderSession.localPublicKey)
responderSession.startAsResponder()
withTimeout(10.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
// Ensure both sessions are authorized
initiatorSession.authorizable = Authorized()
responderSession.authorizable = Authorized()
val smallData = byteArrayOf(1, 2, 3)
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(smallData))
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
assertArrayEquals(smallData, receivedData)
}
@Test
fun sendAndReceive_ExactlyMaximumPacketSize_Succeeds() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onDataB = { _, opcode, subOpcode, data ->
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
val b = ByteArray(data.remaining())
data.get(b)
tcsDataReceived.complete(b)
}
}
)
initiatorSession.startAsInitiator(responderSession.localPublicKey)
responderSession.startAsResponder()
withTimeout(10.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
// Ensure both sessions are authorized
initiatorSession.authorizable = Authorized()
responderSession.authorizable = Authorized()
val maxData = ByteArray(SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE).apply { Random.nextBytes(this) }
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxData))
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
assertArrayEquals(maxData, receivedData)
}
@Test
fun stream_LargeData_Succeeds() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onDataB = { _, opcode, subOpcode, data ->
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
val b = ByteArray(data.remaining())
data.get(b)
tcsDataReceived.complete(b)
}
}
)
initiatorSession.startAsInitiator(responderSession.localPublicKey)
responderSession.startAsResponder()
withTimeout(10.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
// Ensure both sessions are authorized
initiatorSession.authorizable = Authorized()
responderSession.authorizable = Authorized()
val largeData = ByteArray(2 * (SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE)).apply { Random.nextBytes(this) }
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData))
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
assertArrayEquals(largeData, receivedData)
}
@Test
fun authorizedSession_CanSendData() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onDataB = { _, opcode, subOpcode, data ->
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
val b = ByteArray(data.remaining())
data.get(b)
tcsDataReceived.complete(b)
}
}
)
initiatorSession.startAsInitiator(responderSession.localPublicKey)
responderSession.startAsResponder()
withTimeout(10.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
// Authorize both sessions
initiatorSession.authorizable = Authorized()
responderSession.authorizable = Authorized()
val data = byteArrayOf(1, 2, 3)
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data))
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
assertArrayEquals(data, receivedData)
}
@Test
fun unauthorizedSession_CannotSendData() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onDataB = { _, _, _, _ -> }
)
initiatorSession.startAsInitiator(responderSession.localPublicKey)
responderSession.startAsResponder()
withTimeout(10.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
// Authorize initiator but not responder
initiatorSession.authorizable = Authorized()
responderSession.authorizable = Unauthorized()
val data = byteArrayOf(1, 2, 3)
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data))
delay(1.seconds)
assertFalse(tcsDataReceived.isCompleted)
}
@Test
fun directHandshake_WithValidAppId_Succeeds() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val allowedAppId = 1234u
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt ->
linkType == LinkType.Direct && appId == allowedAppId
}
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
isHandshakeAllowed = responderIsHandshakeAllowed
)
initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = allowedAppId)
responderSession.startAsResponder()
withTimeout(5.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
assertNotNull(initiatorSession.remotePublicKey)
assertNotNull(responderSession.remotePublicKey)
}
@Test
fun directHandshake_WithInvalidAppId_Fails() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val allowedAppId = 1234u
val invalidAppId = 5678u
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val initiatorClosed = CompletableDeferred<Boolean>()
val responderClosed = CompletableDeferred<Boolean>()
val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt ->
linkType == LinkType.Direct && appId == allowedAppId
}
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onInitiatorClose = {
initiatorClosed.complete(true)
},
onResponderClose = {
responderClosed.complete(true)
},
isHandshakeAllowed = responderIsHandshakeAllowed
)
initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = invalidAppId)
responderSession.startAsResponder()
withTimeout(5.seconds) {
initiatorClosed.await()
responderClosed.await()
}
assertFalse(handshakeInitiatorCompleted.isCompleted)
assertFalse(handshakeResponderCompleted.isCompleted)
}
}
class Authorized : IAuthorizable {
override val isAuthorized: Boolean = true
}
class Unauthorized : IAuthorizable {
override val isAuthorized: Boolean = false
}
-1
View File
@@ -156,7 +156,6 @@
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.SettingsActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
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",
Mixed: "MIXED",
Live: "LIVE",
Subscriptions: "SUBSCRIPTIONS"
Subscriptions: "SUBSCRIPTIONS",
Shorts: "SHORTS"
},
Order: {
Chronological: "CHRONOLOGICAL"
@@ -244,6 +245,7 @@ class PlatformVideo extends PlatformContent {
this.viewCount = obj.viewCount ?? -1; //Long
this.isLive = obj.isLive ?? false; //Boolean
this.isShort = !!obj.isShort ?? false;
}
}
class PlatformVideoDetails extends PlatformVideo {
@@ -260,6 +262,11 @@ class PlatformVideoDetails extends PlatformVideo {
this.rating = obj.rating ?? null; //IRating
this.subtitles = obj.subtitles ?? [];
this.isShort = !!obj.isShort ?? false;
if (obj.getContentRecommendations) {
this.getContentRecommendations = obj.getContentRecommendations
}
}
}
@@ -14,7 +14,6 @@ import java.text.DecimalFormat
import java.time.OffsetDateTime
import java.time.temporal.ChronoUnit
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.math.roundToLong
@@ -226,6 +225,25 @@ fun Long.toHumanTime(isMs: Boolean): String {
else
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
fun String.fixHtmlWhitespace(): Spanned {
@@ -357,14 +375,19 @@ private val slds = hashSetOf(".com.ac", ".net.ac", ".gov.ac", ".org.ac", ".mil.a
fun String.matchesDomain(queryDomain: String): Boolean {
if(queryDomain.startsWith(".")) {
val parts = queryDomain.lowercase().split(".");
if(parts.size < 3)
val parts = this.lowercase().split(".");
val queryParts = queryDomain.lowercase().trimStart("."[0]).split(".");
if(queryParts.size < 2)
throw IllegalStateException("Illegal use of wildcards on First-Level-Domain (" + queryDomain + ")");
if(parts.size >= 3){
val isSLD = slds.contains("." + parts[parts.size - 2] + "." + parts[parts.size - 1]);
if(isSLD && parts.size <= 3)
else {
val possibleDomain = "." + queryParts.joinToString(".");
if(slds.contains(possibleDomain))
throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")");
/*
val isSLD = slds.contains("." + queryParts[queryParts.size - 2] + "." + queryParts[queryParts.size - 1]);
if(isSLD && queryParts.size <= 3)
throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")");
*/
}
//TODO: Should be safe, but double verify if can't be exploited
@@ -6,6 +6,7 @@ import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
@@ -215,9 +216,14 @@ private fun ByteArray.toInetAddress(): InetAddress {
return InetAddress.getByAddress(this);
}
fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? {
val timeout = 2000
val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance<Inet4Address>() else attemptAddresses;
if(addresses.isEmpty())
throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})");
if (addresses.isEmpty()) {
return null;
}
@@ -1,13 +1,13 @@
package com.futo.platformplayer
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StatePlatform
import com.futo.polycentric.core.ProcessHandle
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.base64UrlToByteArray
import userpackage.Protocol
import kotlin.math.abs
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) }
}
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? {
return StatePlatform.instance.resolveChannelUrlByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
}
fun Protocol.Claim.resolveChannelUrls(): List<String> {
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 {
return when (this) {
is Inet6Address -> {
"[${toString()}]"
"[${hostAddress}]"
}
is Inet4Address -> {
toString()
hostAddress
}
else -> {
throw Exception("Invalid address type")
@@ -205,7 +205,7 @@ class Settings : FragmentedStorageFileJson() {
var home = HomeSettings();
@Serializable
class HomeSettings {
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 5)
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 3)
@DropdownFieldOptionsId(R.array.feed_style)
var homeFeedStyle: Int = 1;
@@ -216,6 +216,11 @@ class Settings : FragmentedStorageFileJson() {
return FeedStyle.THUMBNAIL;
}
@FormField(R.string.show_home_filters, FieldForm.TOGGLE, R.string.show_home_filters_description, 4)
var showHomeFilters: Boolean = true;
@FormField(R.string.show_home_filters_plugin_names, FieldForm.TOGGLE, R.string.show_home_filters_plugin_names_description, 5)
var showHomeFiltersPluginNames: Boolean = false;
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
var previewFeedItems: Boolean = true;
@@ -254,6 +259,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
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 {
if(searchFeedStyle == 0)
@@ -291,6 +299,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.show_subscription_group, FieldForm.TOGGLE, R.string.show_subscription_group_description, 5)
var showSubscriptionGroups: Boolean = true;
@FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6)
var useSubscriptionExchange: Boolean = false;
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
var previewFeedItems: Boolean = true;
@@ -353,7 +364,7 @@ class Settings : FragmentedStorageFileJson() {
var playback = PlaybackSettings();
@Serializable
class PlaybackSettings {
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, -1)
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, -2)
@DropdownFieldOptionsId(R.array.audio_languages)
var primaryLanguage: Int = 0;
@@ -377,6 +388,8 @@ class Settings : FragmentedStorageFileJson() {
else -> null
}
}
@FormField(R.string.prefer_original_audio, FieldForm.TOGGLE, R.string.prefer_original_audio_description, -1)
var preferOriginalAudio: Boolean = true;
//= context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
@@ -570,10 +583,15 @@ class Settings : FragmentedStorageFileJson() {
@Serializable(with = FlexibleBooleanSerializer::class)
var keepScreenOn: Boolean = true;
@FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 1)
@FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 3)
@Serializable(with = FlexibleBooleanSerializer::class)
var alwaysProxyRequests: Boolean = false;
@FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4)
@Serializable(with = FlexibleBooleanSerializer::class)
var allowIpv6: Boolean = false;
/*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@DropdownFieldOptionsId(R.array.preferred_quality_array)
@@ -641,6 +659,9 @@ class Settings : FragmentedStorageFileJson() {
@Serializable
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)
var clearCookiesOnLogout: Boolean = true;
@@ -861,11 +882,13 @@ class Settings : FragmentedStorageFileJson() {
class Other {
@FormField(R.string.playlist_delete_confirmation, FieldForm.TOGGLE, R.string.playlist_delete_confirmation_description, 2)
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;
@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;
}
@@ -913,6 +936,15 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.connect_last, FieldForm.TOGGLE, R.string.connect_last_description, 3)
var connectLast: Boolean = true;
@FormField(R.string.discover_through_relay, FieldForm.TOGGLE, R.string.discover_through_relay_description, 3)
var discoverThroughRelay: Boolean = true;
@FormField(R.string.pair_through_relay, FieldForm.TOGGLE, R.string.pair_through_relay_description, 3)
var pairThroughRelay: Boolean = true;
@FormField(R.string.connect_through_relay, FieldForm.TOGGLE, R.string.connect_through_relay_description, 3)
var connectThroughRelay: Boolean = true;
}
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
@@ -1,6 +1,7 @@
package com.futo.platformplayer
import android.content.Context
import android.content.Intent
import android.webkit.CookieManager
import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder
@@ -5,6 +5,7 @@ import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.Animatable
import android.net.Uri
import android.text.Layout
import android.text.method.ScrollingMovementMethod
@@ -199,16 +200,21 @@ class UIDialogs {
dialog.show();
}
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action) {
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions);
}
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
val builder = AlertDialog.Builder(context);
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
builder.setView(view);
builder.setCancelable(defaultCloseAction > -2);
val dialog = builder.create();
registerDialogOpened(dialog);
view.findViewById<ImageView>(R.id.dialog_icon).apply {
this.setImageResource(icon);
if(animated)
this.drawable.assume<Animatable, Unit> { it.start() };
}
view.findViewById<TextView>(R.id.dialog_text).apply {
this.text = text;
@@ -275,6 +281,7 @@ class UIDialogs {
registerDialogClosed(dialog);
}
dialog.show();
return dialog;
}
fun showGeneralErrorDialog(context: Context, msg: String, ex: Throwable? = null, button: String = "Ok", onOk: (()->Unit)? = null) {
@@ -368,8 +375,8 @@ class UIDialogs {
}
}
fun showChangelogDialog(context: Context, lastVersion: Int) {
val dialog = ChangelogDialog(context);
fun showChangelogDialog(context: Context, lastVersion: Int, changelogs: Map<Int, String>? = null) {
val dialog = ChangelogDialog(context, changelogs);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
@@ -79,6 +79,36 @@ class UISlideOverlays {
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 {
val items = arrayListOf<View>();
@@ -335,7 +365,9 @@ class UISlideOverlays {
call = {
selectedVideoVariant = 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
))
@@ -370,7 +402,7 @@ class UISlideOverlays {
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
slideUpMenuOverlay.hide()
} else if (source is IHLSManifestAudioSource) {
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, sourceUrl), null)
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, sourceUrl), null)
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
slideUpMenuOverlay.hide()
} else {
@@ -417,7 +449,7 @@ class UISlideOverlays {
}
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
listOf(listOf(SlideUpMenuItem(
listOf((if (audioSources != null) listOf(SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
container.context.getString(R.string.none),
@@ -430,7 +462,7 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download));
},
invokeParent = false
)) +
)) else listOf()) +
videoSources
.filter { it.isDownloadable() }
.map {
@@ -652,6 +684,10 @@ class UISlideOverlays {
}
}
}
if(!Settings.instance.downloads.shouldDownload()) {
UIDialogs.appToast("Download will start when you're back on wifi.\n" +
"(You can change this in settings)", true);
}
}
};
return menu.apply { show() };
@@ -895,7 +931,8 @@ class UISlideOverlays {
"${lastUpdated.videos.size} " + container.context.getString(R.string.videos),
tag = "",
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();
}))
);
@@ -906,7 +943,7 @@ class UISlideOverlays {
val watchLater = StatePlaylists.instance.getWatchLater();
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
(listOf(
if(!isLimited)
if(!isLimited && !video.isLive)
SlideUpMenuItem(
container.context,
R.drawable.ic_download,
@@ -991,7 +1028,8 @@ class UISlideOverlays {
"${playlist.videos.size} " + container.context.getString(R.string.videos),
tag = "",
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();
}));
}
@@ -1018,7 +1056,8 @@ class UISlideOverlays {
"${lastUpdated.videos.size} " + container.context.getString(R.string.videos),
tag = "",
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();
}))
);
@@ -1040,7 +1079,10 @@ class UISlideOverlays {
StatePlayer.TYPE_WATCHLATER,
"${watchLater.size} " + container.context.getString(R.string.videos),
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 +1109,8 @@ class UISlideOverlays {
"${playlist.videos.size} " + container.context.getString(R.string.videos),
tag = "",
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();
}));
}
@@ -1109,7 +1152,7 @@ class UISlideOverlays {
container.context.getString(R.string.decide_which_buttons_should_be_pinned),
tag = "",
call = {
showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) {
showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }, {
val selected = it
.map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } }
.filter { it != null }
@@ -1117,7 +1160,7 @@ class UISlideOverlays {
.toList();
onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) });
}
});
},
invokeParent = false
))
@@ -1125,29 +1168,40 @@ class UISlideOverlays {
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.more_options), null, true, *views).apply { show() };
}
fun showOrderOverlay(container: ViewGroup, title: String, options: List<Pair<String, Any>>, onOrdered: (List<Any>)->Unit) {
fun showOrderOverlay(container: ViewGroup, title: String, options: List<Pair<String, Any>>, onOrdered: (List<Any>)->Unit, description: String? = null) {
val selection: MutableList<Any> = mutableListOf();
var overlay: SlideUpMenuOverlay? = null;
overlay = SlideUpMenuOverlay(container.context, container, title, container.context.getString(R.string.save), true,
options.map { SlideUpMenuItem(
listOf(
if(!description.isNullOrEmpty()) SlideUpMenuGroup(container.context, "", description, "", listOf()) else null,
).filterNotNull() +
(options.map { SlideUpMenuItem(
container.context,
R.drawable.ic_move_up,
it.first,
"",
tag = it.second,
call = {
val overlayItem = overlay?.getSlideUpItemByTag(it.second);
if(overlay!!.selectOption(null, it.second, true, true)) {
if(!selection.contains(it.second))
if(!selection.contains(it.second)) {
selection.add(it.second);
} else
if(overlayItem != null) {
overlayItem.setSubText(selection.indexOf(it.second).toString());
}
}
} else {
selection.remove(it.second);
if(overlayItem != null) {
overlayItem.setSubText("");
}
}
},
invokeParent = false
)
});
}));
overlay.onOK.subscribe {
onOrdered.invoke(selection);
overlay.hide();
@@ -27,14 +27,17 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.PlatformVideoWithTime
import com.futo.platformplayer.others.PlatformLinkMovementMethod
import java.io.ByteArrayInputStream
import java.io.File
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.SecureRandom
import java.time.OffsetDateTime
import java.util.*
import java.util.concurrent.ThreadLocalRandom
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
fun getRandomString(sizeOfRandomString: Int): String {
@@ -66,7 +69,14 @@ fun warnIfMainThread(context: String) {
}
fun ensureNotMainThread() {
if (Looper.myLooper() == Looper.getMainLooper()) {
val isMainLooper = try {
Looper.myLooper() == Looper.getMainLooper()
} catch (e: Throwable) {
//Ignore, for unit tests where its not mocked
false
}
if (isMainLooper) {
Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread")
throw IllegalStateException("Cannot run on main thread")
}
@@ -279,3 +289,46 @@ fun ByteBuffer.toUtf8String(): String {
get(remainingBytes)
return String(remainingBytes, Charsets.UTF_8)
}
fun generateReadablePassword(length: Int): String {
val validChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"
val secureRandom = SecureRandom()
val randomBytes = ByteArray(length)
secureRandom.nextBytes(randomBytes)
val sb = StringBuilder(length)
for (byte in randomBytes) {
val index = (byte.toInt() and 0xFF) % validChars.length
sb.append(validChars[index])
}
return sb.toString()
}
fun ByteArray.toGzip(): ByteArray {
if (this == null || this.isEmpty()) return ByteArray(0)
val gzipTimeStart = OffsetDateTime.now();
val outputStream = ByteArrayOutputStream()
GZIPOutputStream(outputStream).use { gzip ->
gzip.write(this)
}
val result = outputStream.toByteArray();
Logger.i("Utility", "Gzip compression time: ${gzipTimeStart.getNowDiffMiliseconds()}ms");
return result;
}
fun ByteArray.fromGzip(): ByteArray {
if (this == null || this.isEmpty()) return ByteArray(0)
val inputStream = ByteArrayInputStream(this)
val outputStream = ByteArrayOutputStream()
GZIPInputStream(inputStream).use { gzip ->
val buffer = ByteArray(1024)
var bytesRead: Int
while (gzip.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
}
}
return outputStream.toByteArray()
}
@@ -10,11 +10,13 @@ import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.*
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput
import com.google.zxing.integration.android.IntentIntegrator
class AddSourceOptionsActivity : AppCompatActivity() {
lateinit var _buttonBack: ImageButton;
lateinit var _overlayContainer: FrameLayout;
lateinit var _buttonQR: BigButton;
lateinit var _buttonBrowse: BigButton;
lateinit var _buttonURL: BigButton;
@@ -54,6 +56,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
setContentView(R.layout.activity_add_source_options);
setNavigationBarColorAndIcons();
_overlayContainer = findViewById(R.id.overlay_container);
_buttonBack = findViewById(R.id.button_back);
_buttonQR = findViewById(R.id.option_qr);
@@ -81,7 +84,25 @@ class AddSourceOptionsActivity : AppCompatActivity() {
}
_buttonURL.onClick.subscribe {
UIDialogs.toast(this, getString(R.string.not_implemented_yet));
val nameInput = SlideUpMenuTextInput(this, "ex. https://yourplugin.com/config.json");
UISlideOverlays.showOverlay(_overlayContainer, "Enter your url", "Install", {
val content = nameInput.text;
val url = if (content.startsWith("https://")) {
content
} else if (content.startsWith("grayjay://plugin/")) {
content.substring("grayjay://plugin/".length)
} else {
UIDialogs.toast(this, getString(R.string.not_a_plugin_url))
return@showOverlay;
}
val intent = Intent(this, AddSourceActivity::class.java).apply {
data = Uri.parse(url);
};
startActivity(intent);
}, nameInput)
}
}
}
@@ -113,7 +113,7 @@ class LoginActivity : AppCompatActivity() {
companion object {
private val TAG = "LoginActivity";
private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#_ ]*");
private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#:_ ]*");
private var _callback: ((SourceAuth?) -> Unit)? = null;
@@ -1281,7 +1281,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (toast.long)
delay(5000);
else
delay(3000);
delay(2500);
}
Logger.i(TAG, "Ending appToast loop");
lifecycleScope.launch(Dispatchers.Main) {
@@ -11,16 +11,16 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.polycentric.PolycentricStorage
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.LoaderView
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ProcessHandle
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -87,7 +87,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
}
processHandle.addServer(PolycentricCache.SERVER);
processHandle.addServer(ApiMethods.SERVER);
processHandle.setUsername(username);
StatePolycentric.instance.setProcessHandle(processHandle);
} catch (e: Throwable) {
@@ -12,12 +12,12 @@ import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.polycentric.PolycentricStorage
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.overlays.LoaderOverlay
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.KeyPair
import com.futo.polycentric.core.Process
import com.futo.polycentric.core.ProcessSecret
@@ -145,7 +145,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
}
StatePolycentric.instance.setProcessHandle(processHandle);
processHandle.fullyBackfillClient(PolycentricCache.SERVER);
processHandle.fullyBackfillClient(ApiMethods.SERVER);
withContext(Dispatchers.Main) {
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
finish();
@@ -21,10 +21,8 @@ import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.dp
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.polycentric.PolycentricStorage
import com.futo.platformplayer.selectBestImage
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.views.buttons.BigButton
import com.futo.platformplayer.views.overlays.LoaderOverlay
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
import com.futo.polycentric.core.toBase64Url
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
@@ -145,7 +145,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
lifecycleScope.launch(Dispatchers.IO) {
try {
processHandle.fullyBackfillClient(PolycentricCache.SERVER)
processHandle.fullyBackfillClient(ApiMethods.SERVER)
withContext(Dispatchers.Main) {
updateUI();
@@ -100,8 +100,10 @@ class SyncHomeActivity : AppCompatActivity() {
private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView {
val connected = session?.connected ?: false
syncDeviceView.setLinkType(if (connected) LinkType.Local else LinkType.None)
.setName(publicKey)
syncDeviceView.setLinkType(session?.linkType ?: LinkType.None)
.setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey)
//TODO: also display public key?
.setStatus(if (connected) "Connected" else "Disconnected")
return syncDeviceView
}
@@ -109,9 +109,9 @@ class SyncPairActivity : AppCompatActivity() {
lifecycleScope.launch(Dispatchers.IO) {
try {
StateSync.instance.connect(deviceInfo) { session, complete, message ->
StateSync.instance.connect(deviceInfo) { complete, message ->
lifecycleScope.launch(Dispatchers.Main) {
if (complete) {
if (complete != null && complete) {
_layoutPairingSuccess.visibility = View.VISIBLE
_layoutPairing.visibility = View.GONE
} else {
@@ -122,7 +122,11 @@ class SyncPairActivity : AppCompatActivity() {
} catch (e: Throwable) {
withContext(Dispatchers.Main) {
_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
Logger.e(TAG, "Failed to pair", e)
}
@@ -67,7 +67,7 @@ class SyncShowPairingCodeActivity : AppCompatActivity() {
}
val ips = getIPs()
val selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT)
val selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT, StateSync.instance.pairingCode)
val json = Json.encodeToString(selfDeviceInfo)
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
val url = "grayjay://sync/${base64}"
@@ -5,6 +5,8 @@ import com.futo.platformplayer.SettingsDev
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.stores.FragmentedStorage
import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
@@ -63,7 +65,7 @@ open class ManagedHttpClient {
constructor(builder: OkHttpClient.Builder = OkHttpClient.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);
client = builder.addNetworkInterceptor { chain ->
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")
current += bytesToSend.toLong()
if (current >= end) {
if (current > end) {
Logger.i(TAG, "Expected amount of bytes sent")
break
}
@@ -1,5 +1,6 @@
package com.futo.platformplayer.api.media
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
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
@@ -66,6 +67,11 @@ interface IPlatformClient {
*/
fun searchChannels(query: String): IPager<PlatformAuthorLink>;
/**
* Searches for channels and returns a content pager
*/
fun searchChannelsAsContent(query: String): IPager<IPlatformContent>;
//Video Pages
/**
@@ -2,7 +2,10 @@ package com.futo.platformplayer.api.media.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.models.JSContent
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
@@ -42,4 +45,21 @@ open class PlatformAuthorLink {
);
}
}
}
interface IPlatformChannelContent : IPlatformContent {
val thumbnail: String?
val subscribers: Long?
}
open class JSChannelContent : JSContent, IPlatformChannelContent {
override val contentType: ContentType get() = ContentType.CHANNEL
override val thumbnail: String?
override val subscribers: Long?
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
val contextName = "Channel";
thumbnail = obj.getOrDefault<String>(config, "thumbnail", contextName, null)
subscribers = if(obj.has("subscribers")) obj.getOrThrow(config,"subscribers", contextName) else null
}
}
@@ -30,6 +30,7 @@ class ResultCapabilities(
const val TYPE_POSTS = "POSTS";
const val TYPE_MIXED = "MIXED";
const val TYPE_SUBSCRIPTIONS = "SUBSCRIPTIONS";
const val TYPE_SHORTS = "SHORTS";
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";
@@ -12,6 +12,7 @@ enum class ContentType(val value: Int) {
URL(9),
NESTED_VIDEO(11),
CHANNEL(60),
LOCKED(70),
@@ -2,6 +2,8 @@ package com.futo.platformplayer.api.media.models.contents
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
import java.time.OffsetDateTime
interface IPlatformContent {
@@ -3,7 +3,7 @@ package com.futo.platformplayer.api.media.models.streams
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.downloads.VideoLocal
class LocalVideoMuxedSourceDescriptor(
class DownloadedVideoMuxedSourceDescriptor(
private val video: VideoLocal
) : VideoMuxedSourceDescriptor() {
override val videoSources: Array<IVideoSource> get() = video.videoSource.toTypedArray();
@@ -13,7 +13,8 @@ class AudioUrlSource(
override val codec: String = "",
override val language: String = Language.UNKNOWN,
override val duration: Long? = null,
override var priority: Boolean = false
override var priority: Boolean = false,
override var original: Boolean = false
) : IAudioUrlSource, IStreamMetaDataSource{
override var streamMetaData: StreamMetaData? = null;
@@ -36,7 +37,9 @@ class AudioUrlSource(
source.container,
source.codec,
source.language,
source.duration
source.duration,
source.priority,
source.original
);
ret.streamMetaData = streamData;
@@ -27,6 +27,7 @@ class HLSVariantAudioUrlSource(
override val language: String,
override val duration: Long?,
override val priority: Boolean,
override val original: Boolean,
val url: String
) : IAudioUrlSource {
override fun getAudioUrl(): String {
@@ -8,4 +8,5 @@ interface IAudioSource {
val language : String;
val duration : Long?;
val priority: Boolean;
val original: Boolean;
}
@@ -15,6 +15,7 @@ class LocalAudioSource : IAudioSource, IStreamMetaDataSource {
override val duration: Long? = null;
override var priority: Boolean = false;
override val original: Boolean = false;
val filePath : String;
val fileSize: Long;
@@ -33,13 +34,13 @@ class LocalAudioSource : IAudioSource, IStreamMetaDataSource {
}
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(
source.name,
path,
fileSize,
source.bitrate,
source.container,
overrideContainer ?: source.container,
source.codec,
source.language
);
@@ -35,7 +35,7 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
}
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(
source.name,
path,
@@ -43,7 +43,7 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
source.width,
source.height,
source.duration,
source.container,
overrideContainer ?: source.container,
source.codec,
source.bitrate?:0
);
@@ -13,4 +13,6 @@ interface IPlatformVideo : IPlatformContent {
val viewCount: Long;
val isLive : Boolean;
val isShort: Boolean;
}
@@ -10,23 +10,26 @@ import com.futo.polycentric.core.combineHashCodes
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNames
import java.time.OffsetDateTime
@kotlinx.serialization.Serializable
open class SerializedPlatformVideo(
override val contentType: ContentType = ContentType.MEDIA,
override val id: PlatformID,
override val name: String,
override val thumbnails: Thumbnails,
override val author: PlatformAuthorLink,
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
@JsonNames("datetime", "dateTime")
override val datetime: OffsetDateTime? = null,
override val url: String,
override val shareUrl: String = "",
override val duration: Long,
override val viewCount: Long,
override val isShort: Boolean = false
) : IPlatformVideo, SerializedPlatformContent {
override val contentType: ContentType = ContentType.MEDIA;
override val isLive: Boolean = false;
@@ -43,6 +46,7 @@ open class SerializedPlatformVideo(
companion object {
fun fromVideo(video: IPlatformVideo) : SerializedPlatformVideo {
return SerializedPlatformVideo(
ContentType.MEDIA,
video.id,
video.name,
video.thumbnails,
@@ -38,7 +38,8 @@ open class SerializedPlatformVideoDetails(
override val video: ISerializedVideoSourceDescriptor,
override val preview: ISerializedVideoSourceDescriptor?,
override val subtitles: List<SubtitleRawSource> = listOf()
override val subtitles: List<SubtitleRawSource> = listOf(),
override val isShort: Boolean = false
) : IPlatformVideo, IPlatformVideoDetails {
final override val contentType: ContentType get() = ContentType.MEDIA;
@@ -10,6 +10,7 @@ import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformClientCapabilities
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
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
@@ -31,6 +32,7 @@ import com.futo.platformplayer.api.media.platforms.js.internal.JSParameterDocs
import com.futo.platformplayer.api.media.platforms.js.models.IJSContent
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSChannel
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelContentPager
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager
import com.futo.platformplayer.api.media.platforms.js.models.JSChapter
import com.futo.platformplayer.api.media.platforms.js.models.JSComment
@@ -361,6 +363,10 @@ open class JSClient : IPlatformClient {
return@isBusyWith JSChannelPager(config, this,
plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"));
}
override fun searchChannelsAsContent(query: String): IPager<IPlatformContent> = isBusyWith("searchChannels") {
ensureEnabled();
return@isBusyWith JSChannelContentPager(config, this, plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"), );
}
@JSDocs(6, "source.isChannelUrl(url)", "Validates if an channel url is for this platform")
@JSDocsParameter("url", "A channel url (May not be your platform)")
@@ -33,6 +33,7 @@ class SourcePluginConfig(
override val allowEval: Boolean = false,
override val allowUrls: List<String> = listOf(),
override val packages: List<String> = listOf(),
override val packagesOptional: List<String> = listOf(),
val settings: List<Setting> = listOf(),
@@ -52,6 +53,7 @@ class SourcePluginConfig(
var allowAllHttpHeaderAccess: Boolean = false,
var maxDownloadParallelism: Int = 0,
var reduceFunctionsInLimitedVersion: Boolean = false,
var changelog: HashMap<String, List<String>>? = null
) : IV8PluginConfig {
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
@@ -101,6 +103,10 @@ class SourcePluginConfig(
if(!packages.contains(pack))
return false;
}
for(pack in newConfig.packagesOptional) {
if(!packagesOptional.contains(pack))
return false;
}
//Developer Submit Url should be same or empty
if(!newConfig.developerSubmitUrl.isNullOrEmpty() && developerSubmitUrl != newConfig.developerSubmitUrl)
return false;
@@ -129,7 +135,7 @@ class SourcePluginConfig(
val currentlyInstalledPlugin = StatePlugins.instance.getPlugin(id);
if (currentlyInstalledPlugin != null) {
if (currentlyInstalledPlugin.config.scriptPublicKey != scriptPublicKey) {
if (currentlyInstalledPlugin.config.scriptPublicKey != scriptPublicKey && !currentlyInstalledPlugin.config.scriptPublicKey.isNullOrEmpty()) {
list.add(Pair(
"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."));
@@ -178,6 +184,19 @@ class SourcePluginConfig(
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 {
fun fromJson(json: String, sourceUrl: String? = null): SourcePluginConfig {
val obj = Serializer.json.decodeFromString<SourcePluginConfig>(json);
@@ -1,6 +1,7 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.JSChannelContent
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient
@@ -26,6 +27,7 @@ interface IJSContent: IPlatformContent {
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
ContentType.PLAYLIST -> JSPlaylist(config, obj);
ContentType.LOCKED -> JSLockedContent(config, obj);
ContentType.CHANNEL -> JSChannelContent(config, obj)
else -> throw NotImplementedError("Unknown content type ${type}");
}
}
@@ -5,7 +5,6 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.V8Plugin
class JSChannelPager : JSPager<PlatformAuthorLink>, IPager<PlatformAuthorLink> {
@@ -49,8 +49,8 @@ open class JSContent : IPlatformContent, IPluginSourced {
else
author = PlatformAuthorLink.UNKNOWN;
val datetimeInt = _content.getOrThrow<Int>(config, "datetime", contextName).toLong();
if(datetimeInt == 0.toLong())
val datetimeInt = _content.getOrDefault<Int>(config, "datetime", contextName, null)?.toLong();
if(datetimeInt == null || datetimeInt == 0.toLong())
datetime = null;
else
datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.models.JSChannelContent
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
@@ -15,4 +16,14 @@ class JSContentPager : JSPager<IPlatformContent>, IPluginSourced {
override fun convertResult(obj: V8ValueObject): IPlatformContent {
return IJSContent.fromV8(plugin, obj);
}
}
class JSChannelContentPager : JSPager<IPlatformContent>, IPluginSourced {
override val sourceConfig: SourcePluginConfig get() = config;
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {}
override fun convertResult(obj: V8ValueObject): IPlatformContent {
return JSChannelContent(config, obj);
}
}
@@ -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.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
@@ -17,6 +18,7 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
final override val viewCount: Long;
final override val isLive: Boolean;
final override val isShort: Boolean;
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformVideo";
@@ -26,5 +28,6 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
duration = _content.getOrThrow<Int>(config, "duration", contextName).toLong();
viewCount = _content.getOrThrow(config, "viewCount", contextName);
isLive = _content.getOrThrow(config, "isLive", contextName);
isShort = _content.getOrDefault(config, "isShort", contextName, false) ?: false;
}
}
@@ -21,6 +21,8 @@ open class JSAudioUrlSource : IAudioUrlSource, JSSource {
override var priority: Boolean = false;
override var original: Boolean = false;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_AUDIOURL, plugin, obj) {
val contextName = "AudioUrlSource";
val config = plugin.config;
@@ -35,6 +37,7 @@ open class JSAudioUrlSource : IAudioUrlSource, JSSource {
name = _obj.getOrDefault(config, "name", contextName, "${container} ${bitrate}") ?: "${container} ${bitrate}";
priority = if(_obj.has("priority")) obj.getOrThrow(config, "priority", contextName) else false;
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
}
override fun getAudioUrl() : String {
@@ -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.IDashManifestSource
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.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig
@@ -14,13 +16,14 @@ import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.states.StateDeveloper
class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawSource {
override val container : String = "application/dash+xml";
class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawSource, IStreamMetaDataSource {
override val container : String;
override val name : String;
override val codec: String;
override val bitrate: Int;
override val duration: Long;
override val priority: Boolean;
override var original: Boolean = false;
override val language: String;
@@ -29,17 +32,21 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
override val hasGenerate: Boolean;
override var streamMetaData: StreamMetaData? = null;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) {
val contextName = "DashRawSource";
val config = plugin.config;
name = _obj.getOrThrow(config, "name", contextName);
url = _obj.getOrThrow(config, "url", contextName);
container = _obj.getOrDefault<String>(config, "container", contextName, null) ?: "application/dash+xml";
manifest = _obj.getOrThrow(config, "manifest", contextName);
codec = _obj.getOrDefault(config, "codec", contextName, "") ?: "";
bitrate = _obj.getOrDefault(config, "bitrate", contextName, 0) ?: 0;
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN;
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
hasGenerate = _obj.has("generate");
}
@@ -50,15 +57,28 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
throw IllegalStateException("Source object already closed");
val plugin = _plugin.getUnderlyingPlugin();
var result: String? = null;
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()") {
_obj.invokeString("generate");
}
}
else
return _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.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.IVideoSource
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.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig
@@ -20,8 +22,8 @@ interface IJSDashManifestRawSource {
var manifest: String?;
fun generate(): String?;
}
open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSource {
override val container : String = "application/dash+xml";
open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSource, IStreamMetaDataSource {
override val container : String;
override val name : String;
override val width: Int;
override val height: Int;
@@ -36,11 +38,14 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
override val hasGenerate: Boolean;
val canMerge: Boolean;
override var streamMetaData: StreamMetaData? = null;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) {
val contextName = "DashRawSource";
val config = plugin.config;
name = _obj.getOrThrow(config, "name", 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);
width = _obj.getOrDefault(config, "width", contextName, 0) ?: 0;
height = _obj.getOrDefault(config, "height", contextName, 0) ?: 0;
@@ -57,17 +62,30 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
return manifest;
if(_obj.isClosed)
throw IllegalStateException("Source object already closed");
var result: String? = null;
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()", {
_obj.invokeString("generate");
});
}
}
else
return _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "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;
//TODO: Temporary simple solution..make more reliable version
var result: String? = null;
val audioAdaptationSet = adaptationSetRegex.find(audioDash!!);
if(audioAdaptationSet != null) {
return videoDash.replace("</AdaptationSet>", "</AdaptationSet>\n" + audioAdaptationSet.value)
result = videoDash.replace("</AdaptationSet>", "</AdaptationSet>\n" + audioAdaptationSet.value)
}
else
return videoDash;
result = videoDash;
return result;
}
companion object {
@@ -21,6 +21,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
override val language: String;
override var priority: Boolean = false;
override var original: Boolean = false;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
val contextName = "HLSAudioSource";
@@ -32,6 +33,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
language = _obj.getOrThrow(config, "language", contextName);
priority = obj.getOrNull(config, "priority", contextName) ?: false;
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
}
@@ -0,0 +1,5 @@
package com.futo.platformplayer.api.media.platforms.local
class LocalClient {
//TODO
}
@@ -0,0 +1,85 @@
package com.futo.platformplayer.api.media.platforms.local.models
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
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.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.downloads.VideoLocal
import java.io.File
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneId
class LocalVideoDetails: IPlatformVideoDetails {
override val contentType: ContentType get() = ContentType.UNKNOWN;
override val id: PlatformID;
override val name: String;
override val author: PlatformAuthorLink;
override val datetime: OffsetDateTime?;
override val url: String;
override val shareUrl: String;
override val rating: IRating = RatingLikes(0);
override val description: String = "";
override val video: IVideoSourceDescriptor;
override val preview: IVideoSourceDescriptor? = null;
override val live: IVideoSource? = null;
override val dash: IDashManifestSource? = null;
override val hls: IHLSManifestSource? = null;
override val subtitles: List<ISubtitleSource> = listOf()
override val thumbnails: Thumbnails;
override val duration: Long;
override val viewCount: Long = 0;
override val isLive: Boolean = false;
override val isShort: Boolean = false;
constructor(file: File) {
id = PlatformID("Local", file.path, "LOCAL")
name = file.name;
author = PlatformAuthorLink.UNKNOWN;
url = file.canonicalPath;
shareUrl = "";
duration = 0;
thumbnails = Thumbnails(arrayOf());
datetime = OffsetDateTime.ofInstant(
Instant.ofEpochMilli(file.lastModified()),
ZoneId.systemDefault()
);
video = LocalVideoMuxedSourceDescriptor(LocalVideoFileSource(file));
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
return null;
}
override fun getPlaybackTracker(): IPlaybackTracker? {
return null;
}
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
return null;
}
}
@@ -0,0 +1,13 @@
package com.futo.platformplayer.api.media.platforms.local.models
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
import com.futo.platformplayer.downloads.VideoLocal
class LocalVideoMuxedSourceDescriptor(
private val video: LocalVideoFileSource
) : VideoMuxedSourceDescriptor() {
override val videoSources: Array<IVideoSource> get() = arrayOf(video);
}
@@ -0,0 +1,25 @@
package com.futo.platformplayer.api.media.platforms.local.models
import android.content.Context
import android.database.Cursor
import android.provider.MediaStore
import android.provider.MediaStore.Video
class MediaStoreVideo {
companion object {
val URI = MediaStore.Files.getContentUri("external");
val PROJECTION = arrayOf(Video.Media._ID, Video.Media.TITLE, Video.Media.DURATION, Video.Media.HEIGHT, Video.Media.WIDTH, Video.Media.MIME_TYPE);
val ORDER = MediaStore.Video.Media.TITLE;
fun readMediaStoreVideo(cursor: Cursor) {
}
fun query(context: Context, selection: String, args: Array<String>, order: String? = null): Cursor? {
val cursor = context.contentResolver.query(URI, PROJECTION, selection, args, order ?: ORDER, null);
return cursor;
}
}
}
@@ -0,0 +1,31 @@
package com.futo.platformplayer.api.media.platforms.local.models.sources
import android.content.Context
import android.provider.MediaStore
import android.provider.MediaStore.Video
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
import com.futo.platformplayer.helpers.VideoHelper
import java.io.File
class LocalVideoFileSource: IVideoSource {
override val name: String;
override val width: Int;
override val height: Int;
override val container: String;
override val codec: String = ""
override val bitrate: Int = 0
override val duration: Long;
override val priority: Boolean = false;
constructor(file: File) {
name = file.name;
width = 0;
height = 0;
container = VideoHelper.videoExtensionToMimetype(file.extension) ?: "";
duration = 0;
}
}
@@ -6,7 +6,7 @@ import com.futo.platformplayer.constructs.Event1
* A RefreshPager represents a pager that can be modified overtime (eg. By getting more results later, by recreating the pager)
* When the onPagerChanged event is emitted, a new pager instance is passed, or requested via getCurrentPager
*/
interface IRefreshPager<T> {
interface IRefreshPager<T>: IPager<T> {
val onPagerChanged: Event1<IPager<T>>;
val onPagerError: Event1<Throwable>;
@@ -1,5 +1,7 @@
package com.futo.platformplayer.api.media.structures
import com.futo.platformplayer.api.media.structures.ReusablePager.Window
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
/**
@@ -9,8 +11,8 @@ import com.futo.platformplayer.logging.Logger
* A "Window" is effectively a pager that just reads previous results from the shared results, but when the end is reached, it will call nextPage on the parent if possible for new results.
* This allows multiple Windows to exist of the same pager, without messing with position, or duplicate requests
*/
class ReusablePager<T>: INestedPager<T>, IPager<T> {
private val _pager: IPager<T>;
open class ReusablePager<T>: INestedPager<T>, IReusablePager<T> {
protected var _pager: IPager<T>;
val previousResults = arrayListOf<T>();
constructor(subPager: IPager<T>) {
@@ -44,7 +46,7 @@ class ReusablePager<T>: INestedPager<T>, IPager<T> {
return previousResults;
}
fun getWindow(): Window<T> {
override fun getWindow(): Window<T> {
return Window(this);
}
@@ -95,4 +97,118 @@ class ReusablePager<T>: INestedPager<T>, IPager<T> {
return ReusablePager(this);
}
}
}
public class ReusableRefreshPager<T>: INestedPager<T>, IReusablePager<T> {
protected var _pager: IRefreshPager<T>;
val previousResults = arrayListOf<T>();
private var _currentPage: IPager<T>;
val onPagerChanged = Event1<IPager<T>>()
val onPagerError = Event1<Throwable>()
constructor(subPager: IRefreshPager<T>) {
this._pager = subPager;
_currentPage = this;
synchronized(previousResults) {
previousResults.addAll(subPager.getResults());
}
_pager.onPagerError.subscribe(onPagerError::emit);
_pager.onPagerChanged.subscribe {
_currentPage = it;
synchronized(previousResults) {
previousResults.clear();
previousResults.addAll(it.getResults());
}
onPagerChanged.emit(_currentPage);
};
}
override fun findPager(query: (IPager<T>) -> Boolean): IPager<T>? {
if(query(_pager))
return _pager;
else if(_pager is INestedPager<*>)
return (_pager as INestedPager<T>).findPager(query);
return null;
}
override fun hasMorePages(): Boolean {
return _pager.hasMorePages();
}
override fun nextPage() {
_pager.nextPage();
}
override fun getResults(): List<T> {
val results = _pager.getResults();
synchronized(previousResults) {
previousResults.addAll(results);
}
return previousResults;
}
override fun getWindow(): RefreshWindow<T> {
return RefreshWindow(this);
}
class RefreshWindow<T>: IPager<T>, INestedPager<T>, IRefreshPager<T> {
private val _parent: ReusableRefreshPager<T>;
private var _position: Int = 0;
private var _read: Int = 0;
private var _currentResults: List<T>;
override val onPagerChanged = Event1<IPager<T>>();
override val onPagerError = Event1<Throwable>();
override fun getCurrentPager(): IPager<T> {
return _parent.getWindow();
}
constructor(parent: ReusableRefreshPager<T>) {
_parent = parent;
synchronized(_parent.previousResults) {
_currentResults = _parent.previousResults.toList();
_read += _currentResults.size;
}
parent.onPagerChanged.subscribe(onPagerChanged::emit);
parent.onPagerError.subscribe(onPagerError::emit);
}
override fun hasMorePages(): Boolean {
return _parent.previousResults.size > _read || _parent.hasMorePages();
}
override fun nextPage() {
synchronized(_parent.previousResults) {
if (_parent.previousResults.size <= _read) {
_parent.nextPage();
_parent.getResults();
}
_currentResults = _parent.previousResults.drop(_read).toList();
_read += _currentResults.size;
}
}
override fun getResults(): List<T> {
return _currentResults;
}
override fun findPager(query: (IPager<T>) -> Boolean): IPager<T>? {
return _parent.findPager(query);
}
}
}
interface IReusablePager<T>: IPager<T> {
fun getWindow(): IPager<T>;
}
@@ -3,6 +3,7 @@ package com.futo.platformplayer.casting
import android.os.Looper
import android.util.Base64
import android.util.Log
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
@@ -32,6 +33,7 @@ import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.math.BigInteger
import java.net.Inet4Address
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
@@ -90,7 +92,7 @@ class FCastCastingDevice : CastingDevice {
private var _version: Long = 1;
private var _thread: Thread? = null
private var _pingThread: Thread? = null
private var _lastPongTime = -1L
@Volatile private var _lastPongTime = System.currentTimeMillis()
private var _outputStreamLock = Object()
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
@@ -324,9 +326,9 @@ class FCastCastingDevice : CastingDevice {
continue;
}
localAddress = _socket?.localAddress;
connectionState = CastConnectionState.CONNECTED;
_lastPongTime = -1L
localAddress = _socket?.localAddress
_lastPongTime = System.currentTimeMillis()
connectionState = CastConnectionState.CONNECTED
val buffer = ByteArray(4096);
@@ -402,36 +404,32 @@ class FCastCastingDevice : CastingDevice {
_pingThread = Thread {
Logger.i(TAG, "Started ping loop.")
while (_scopeIO?.isActive == true) {
try {
send(Opcode.Ping)
} catch (e: Throwable) {
Log.w(TAG, "Failed to send ping.")
if (connectionState == CastConnectionState.CONNECTED) {
try {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
send(Opcode.Ping)
if (System.currentTimeMillis() - _lastPongTime > 15000) {
Logger.w(TAG, "Closing socket due to last pong time being larger than 15 seconds.")
try {
_socket?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
Log.w(TAG, "Failed to send ping.")
try {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}
}
/*if (_lastPongTime != -1L && System.currentTimeMillis() - _lastPongTime > 6000) {
Logger.w(TAG, "Closing socket due to last pong time being larger than 6 seconds.")
try {
_socket?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}*/
Thread.sleep(2000)
Thread.sleep(5000)
}
Logger.i(TAG, "Stopped ping loop.");
Logger.i(TAG, "Stopped ping loop.")
}.apply { start() }
} else {
Log.i(TAG, "Thread was still alive, not restarted")
@@ -1,14 +1,18 @@
package com.futo.platformplayer.casting
import android.app.AlertDialog
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.Looper
import android.util.Base64
import android.util.Log
import android.util.Xml
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
@@ -38,8 +42,6 @@ import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.mdns.DnsService
import com.futo.platformplayer.mdns.ServiceDiscoverer
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.states.StateApp
@@ -53,7 +55,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.net.InetAddress
import java.net.URLDecoder
import java.net.URLEncoder
@@ -64,11 +65,10 @@ class StateCasting {
private val _scopeMain = CoroutineScope(Dispatchers.Main);
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
private val _castServer = ManagedHttpServer(9999);
private val _castServer = ManagedHttpServer();
private var _started = false;
var devices: HashMap<String, CastingDevice> = hashMapOf();
var rememberedDevices: ArrayList<CastingDevice> = arrayListOf();
val onDeviceAdded = Event1<CastingDevice>();
val onDeviceChanged = Event1<CastingDevice>();
val onDeviceRemoved = Event1<CastingDevice>();
@@ -82,48 +82,15 @@ class StateCasting {
private var _audioExecutor: JSRequestExecutor? = null
private val _client = ManagedHttpClient();
var _resumeCastingDevice: CastingDeviceInfo? = null;
val _serviceDiscoverer = ServiceDiscoverer(arrayOf(
"_googlecast._tcp.local",
"_airplay._tcp.local",
"_fastcast._tcp.local",
"_fcast._tcp.local"
)) { handleServiceUpdated(it) }
private var _nsdManager: NsdManager? = null
val isCasting: Boolean get() = activeDevice != null;
private fun handleServiceUpdated(services: List<DnsService>) {
for (s in services) {
//TODO: Addresses IPv4 only?
val addresses = s.addresses.toTypedArray()
val port = s.port.toInt()
var name = s.texts.firstOrNull { it.startsWith("md=") }?.substring("md=".length)
if (s.name.endsWith("._googlecast._tcp.local")) {
if (name == null) {
name = s.name.substring(0, s.name.length - "._googlecast._tcp.local".length)
}
addOrUpdateChromeCastDevice(name, addresses, port)
} else if (s.name.endsWith("._airplay._tcp.local")) {
if (name == null) {
name = s.name.substring(0, s.name.length - "._airplay._tcp.local".length)
}
addOrUpdateAirPlayDevice(name, addresses, port)
} else if (s.name.endsWith("._fastcast._tcp.local")) {
if (name == null) {
name = s.name.substring(0, s.name.length - "._fastcast._tcp.local".length)
}
addOrUpdateFastCastDevice(name, addresses, port)
} else if (s.name.endsWith("._fcast._tcp.local")) {
if (name == null) {
name = s.name.substring(0, s.name.length - "._fcast._tcp.local".length)
}
addOrUpdateFastCastDevice(name, addresses, port)
}
}
}
private val _discoveryListeners = mapOf(
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
)
fun handleUrl(context: Context, url: String) {
val uri = Uri.parse(url)
@@ -188,30 +155,29 @@ class StateCasting {
Logger.i(TAG, "CastingService starting...");
rememberedDevices.clear();
rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) });
_castServer.start();
enableDeveloper(true);
Logger.i(TAG, "CastingService started.");
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
}
@Synchronized
fun startDiscovering() {
try {
_serviceDiscoverer.start()
} catch (e: Throwable) {
Logger.i(TAG, "Failed to start ServiceDiscoverer", e)
_nsdManager?.apply {
_discoveryListeners.forEach {
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
}
}
}
@Synchronized
fun stopDiscovering() {
try {
_serviceDiscoverer.stop()
} catch (e: Throwable) {
Logger.i(TAG, "Failed to stop ServiceDiscoverer", e)
_nsdManager?.apply {
_discoveryListeners.forEach {
stopServiceDiscovery(it.value)
}
}
}
@@ -237,8 +203,82 @@ class StateCasting {
_castServer.removeAllHandlers();
Logger.i(TAG, "CastingService stopped.")
_nsdManager = null
}
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
return object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(regType: String) {
Log.d(TAG, "Service discovery started for $regType")
}
override fun onDiscoveryStopped(serviceType: String) {
Log.i(TAG, "Discovery stopped: $serviceType")
}
override fun onServiceLost(service: NsdServiceInfo) {
Log.e(TAG, "service lost: $service")
// TODO: Handle service lost, e.g., remove device
}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
_nsdManager?.stopServiceDiscovery(this)
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
_nsdManager?.stopServiceDiscovery(this)
}
override fun onServiceFound(service: NsdServiceInfo) {
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
service.hostAddresses.toTypedArray()
} else {
arrayOf(service.host)
}
addOrUpdate(service.serviceName, addresses, service.port)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
_nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback {
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "onServiceUpdated: $serviceInfo")
addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port)
}
override fun onServiceLost() {
Log.v(TAG, "onServiceLost: $service")
// TODO: Handle service lost
}
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
}
override fun onServiceInfoCallbackUnregistered() {
Log.v(TAG, "onServiceInfoCallbackUnregistered")
}
})
} else {
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
Log.v(TAG, "Resolve failed: $errorCode")
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port)
}
})
}
}
}
}
private val _castingDialogLock = Any();
private var _currentDialog: AlertDialog? = null;
@Synchronized
fun connectDevice(device: CastingDevice) {
if (activeDevice == device)
@@ -272,10 +312,41 @@ class StateCasting {
invokeInMainScopeIfRequired {
StateApp.withContext(false) { context ->
context.let {
Logger.i(TAG, "Casting state changed to ${castConnectionState}");
when (castConnectionState) {
CastConnectionState.CONNECTED -> UIDialogs.toast(it, "Connected to device")
CastConnectionState.CONNECTING -> UIDialogs.toast(it, "Connecting to device...")
CastConnectionState.DISCONNECTED -> UIDialogs.toast(it, "Disconnected from device")
CastConnectionState.CONNECTED -> {
Logger.i(TAG, "Casting connected to [${device.name}]");
UIDialogs.appToast("Connected to device")
synchronized(_castingDialogLock) {
if(_currentDialog != null) {
_currentDialog?.hide();
_currentDialog = null;
}
}
}
CastConnectionState.CONNECTING -> {
Logger.i(TAG, "Casting connecting to [${device.name}]");
UIDialogs.toast(it, "Connecting to device...")
synchronized(_castingDialogLock) {
if(_currentDialog == null) {
_currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true,
"Connecting to [${device.name}]",
"Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2,
UIDialogs.Action("Disconnect", {
device.stop();
}));
}
}
}
CastConnectionState.DISCONNECTED -> {
UIDialogs.toast(it, "Disconnected from device")
synchronized(_castingDialogLock) {
if(_currentDialog != null) {
_currentDialog?.hide();
_currentDialog = null;
}
}
}
}
}
};
@@ -295,9 +366,6 @@ class StateCasting {
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
};
addRememberedDevice(device);
Logger.i(TAG, "Device added to active discovery. Active discovery now contains ${_storage.getDevicesCount()} devices.")
try {
device.start();
} catch (e: Throwable) {
@@ -319,21 +387,22 @@ class StateCasting {
return addRememberedDevice(device);
}
fun getRememberedCastingDevices(): List<CastingDevice> {
return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) }
}
fun getRememberedCastingDeviceNames(): List<String> {
return _storage.getDeviceNames()
}
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
val deviceInfo = device.getDeviceInfo()
val foundInfo = _storage.addDevice(deviceInfo)
if (foundInfo == deviceInfo) {
rememberedDevices.add(device);
return foundInfo;
}
return foundInfo;
return _storage.addDevice(deviceInfo)
}
fun removeRememberedDevice(device: CastingDevice) {
val name = device.name ?: return;
_storage.removeDevice(name);
rememberedDevices.remove(device);
val name = device.name ?: return
_storage.removeDevice(name)
}
private fun invokeInMainScopeIfRequired(action: () -> Unit){
@@ -1,37 +1,24 @@
package com.futo.platformplayer.dialogs
import android.app.AlertDialog
import android.app.PendingIntent.*
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.graphics.drawable.Animatable
import android.os.Bundle
import android.text.method.ScrollingMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.widget.Button
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.receivers.InstallReceiver
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.fragment.mainactivity.main.ChannelFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
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 {
private val TAG = "ChangelogDialog";
}
@@ -48,7 +35,11 @@ class ChangelogDialog(context: Context?) : AlertDialog(context) {
private var _maxVersion: Int = 0;
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); }
.exception<Throwable> {
Logger.w(TAG, "Failed to load changelog.", it);
@@ -97,7 +88,7 @@ class ChangelogDialog(context: Context?) : AlertDialog(context) {
setVersion(version);
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) {
@@ -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.constructs.Event1
import com.futo.platformplayer.dp
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.selectBestImage
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.Store
import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.android.material.button.MaterialButton
@@ -9,7 +9,9 @@ import android.view.View
import android.widget.Button
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
@@ -21,22 +23,21 @@ import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.adapters.DeviceAdapter
import com.futo.platformplayer.views.adapters.DeviceAdapterEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
private lateinit var _imageLoader: ImageView;
private lateinit var _buttonClose: Button;
private lateinit var _buttonAdd: ImageButton;
private lateinit var _buttonScanQR: ImageButton;
private lateinit var _buttonAdd: LinearLayout;
private lateinit var _buttonScanQR: LinearLayout;
private lateinit var _textNoDevicesFound: TextView;
private lateinit var _textNoDevicesRemembered: TextView;
private lateinit var _recyclerDevices: RecyclerView;
private lateinit var _recyclerRememberedDevices: RecyclerView;
private lateinit var _adapter: DeviceAdapter;
private lateinit var _rememberedAdapter: DeviceAdapter;
private val _devices: ArrayList<CastingDevice> = arrayListOf();
private val _rememberedDevices: ArrayList<CastingDevice> = arrayListOf();
private val _devices: MutableSet<String> = mutableSetOf()
private val _rememberedDevices: MutableSet<String> = mutableSetOf()
private val _unifiedDevices: MutableList<DeviceAdapterEntry> = mutableListOf()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
@@ -45,42 +46,40 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
_imageLoader = findViewById(R.id.image_loader);
_buttonClose = findViewById(R.id.button_close);
_buttonAdd = findViewById(R.id.button_add);
_buttonScanQR = findViewById(R.id.button_scan_qr);
_buttonScanQR = findViewById(R.id.button_qr);
_recyclerDevices = findViewById(R.id.recycler_devices);
_recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices);
_textNoDevicesFound = findViewById(R.id.text_no_devices_found);
_textNoDevicesRemembered = findViewById(R.id.text_no_devices_remembered);
_adapter = DeviceAdapter(_devices, false);
_adapter = DeviceAdapter(_unifiedDevices)
_recyclerDevices.adapter = _adapter;
_recyclerDevices.layoutManager = LinearLayoutManager(context);
_rememberedAdapter = DeviceAdapter(_rememberedDevices, true);
_rememberedAdapter.onRemove.subscribe { d ->
if (StateCasting.instance.activeDevice == d) {
d.stopCasting();
_adapter.onPin.subscribe { d ->
val isRemembered = _rememberedDevices.contains(d.name)
val newIsRemembered = !isRemembered
if (newIsRemembered) {
StateCasting.instance.addRememberedDevice(d)
val name = d.name
if (name != null) {
_rememberedDevices.add(name)
}
} else {
StateCasting.instance.removeRememberedDevice(d)
_rememberedDevices.remove(d.name)
}
StateCasting.instance.removeRememberedDevice(d);
val index = _rememberedDevices.indexOf(d);
if (index != -1) {
_rememberedDevices.removeAt(index);
_rememberedAdapter.notifyItemRemoved(index);
}
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
};
_rememberedAdapter.onConnect.subscribe { _ ->
dismiss()
UIDialogs.showCastingDialog(context)
updateUnifiedList()
}
//TODO: Integrate remembered into the main list
//TODO: Add green indicator to indicate a device is oneline
//TODO: Add pinning
//TODO: Implement QR code as an option in add manually
//TODO: Remove start button
_adapter.onConnect.subscribe { _ ->
dismiss()
UIDialogs.showCastingDialog(context)
//UIDialogs.showCastingDialog(context)
}
_recyclerRememberedDevices.adapter = _rememberedAdapter;
_recyclerRememberedDevices.layoutManager = LinearLayoutManager(context);
_buttonClose.setOnClickListener { dismiss(); };
_buttonAdd.setOnClickListener {
@@ -105,77 +104,112 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
Logger.i(TAG, "Dialog shown.");
StateCasting.instance.startDiscovering()
(_imageLoader.drawable as Animatable?)?.start();
_devices.clear();
synchronized (StateCasting.instance.devices) {
_devices.addAll(StateCasting.instance.devices.values);
synchronized(StateCasting.instance.devices) {
_devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
}
_rememberedDevices.clear();
synchronized (StateCasting.instance.rememberedDevices) {
_rememberedDevices.addAll(StateCasting.instance.rememberedDevices);
_rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
updateUnifiedList()
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
val name = d.name
if (name != null)
_devices.add(name)
updateUnifiedList()
}
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name }
if (index != -1) {
_unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice)
_adapter.notifyItemChanged(index)
}
}
StateCasting.instance.onDeviceRemoved.subscribe(this) { d ->
_devices.remove(d.name)
updateUnifiedList()
}
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
if (connectionState == CastConnectionState.CONNECTED) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
dismiss()
}
}
}
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
_devices.add(d);
_adapter.notifyItemInserted(_devices.size - 1);
_textNoDevicesFound.visibility = View.GONE;
_recyclerDevices.visibility = View.VISIBLE;
};
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
val index = _devices.indexOf(d);
if (index == -1) {
return@subscribe;
}
_devices[index] = d;
_adapter.notifyItemChanged(index);
};
StateCasting.instance.onDeviceRemoved.subscribe(this) { d ->
val index = _devices.indexOf(d);
if (index == -1) {
return@subscribe;
}
_devices.removeAt(index);
_adapter.notifyItemRemoved(index);
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
};
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
if (connectionState != CastConnectionState.CONNECTED) {
return@subscribe;
}
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
dismiss();
};
};
_adapter.notifyDataSetChanged();
_rememberedAdapter.notifyDataSetChanged();
}
override fun dismiss() {
super.dismiss();
(_imageLoader.drawable as Animatable?)?.stop();
super.dismiss()
(_imageLoader.drawable as Animatable?)?.stop()
StateCasting.instance.stopDiscovering()
StateCasting.instance.onDeviceAdded.remove(this);
StateCasting.instance.onDeviceChanged.remove(this);
StateCasting.instance.onDeviceRemoved.remove(this);
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
StateCasting.instance.onDeviceAdded.remove(this)
StateCasting.instance.onDeviceChanged.remove(this)
StateCasting.instance.onDeviceRemoved.remove(this)
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this)
}
private fun updateUnifiedList() {
val oldList = ArrayList(_unifiedDevices)
val newList = buildUnifiedList()
val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun getOldListSize() = oldList.size
override fun getNewListSize() = newList.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return oldItem.castingDevice.name == newItem.castingDevice.name
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return oldItem.castingDevice.name == newItem.castingDevice.name
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
}
})
_unifiedDevices.clear()
_unifiedDevices.addAll(newList)
diffResult.dispatchUpdatesTo(_adapter)
_textNoDevicesFound.visibility = if (_unifiedDevices.isEmpty()) View.VISIBLE else View.GONE
_recyclerDevices.visibility = if (_unifiedDevices.isNotEmpty()) View.VISIBLE else View.GONE
}
private fun buildUnifiedList(): List<DeviceAdapterEntry> {
val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name }
val rememberedDevices = StateCasting.instance.getRememberedCastingDevices().associateBy { it.name }
val unifiedList = mutableListOf<DeviceAdapterEntry>()
val intersectionNames = _devices.intersect(_rememberedDevices)
for (name in intersectionNames) {
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, true)) }
}
val onlineOnlyNames = _devices - _rememberedDevices
for (name in onlineOnlyNames) {
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, false, true)) }
}
val rememberedOnlyNames = _rememberedDevices - _devices
for (name in rememberedOnlyNames) {
rememberedDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, false)) }
}
return unifiedList
}
companion object {
@@ -54,6 +54,7 @@ class PluginUpdateDialog : AlertDialog {
private lateinit var _buttonInstall: LinearLayout;
private lateinit var _textPlugin: TextView;
private lateinit var _textChangelog: TextView;
private lateinit var _textProgres: TextView;
private lateinit var _textError: TextView;
private lateinit var _textResult: TextView;
@@ -94,6 +95,7 @@ class PluginUpdateDialog : AlertDialog {
_buttonInstall = findViewById(R.id.button_install);
_textPlugin = findViewById(R.id.text_plugin);
_textChangelog = findViewById(R.id.text_changelog);
_textProgres = findViewById(R.id.text_progress);
_textError = findViewById(R.id.text_error);
_textResult = findViewById(R.id.text_result);
@@ -110,6 +112,27 @@ class PluginUpdateDialog : AlertDialog {
_updateSpinner = findViewById(R.id.update_spinner);
_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 {
dismiss();
};
@@ -141,11 +141,17 @@ class VideoDownload {
var error: 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 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 subtitleFilePath: String? = null;
@@ -235,11 +241,13 @@ class VideoDownload {
videoDetails = null;
videoSource = null;
videoSourceLive = null;
videoOverrideContainer = null;
}
if(requiresLiveAudioSource && !isLiveAudioSourceValid) {
videoDetails = null;
audioSource = null;
videoSourceLive = null;
audioOverrideContainer = null;
}
if(video == null && videoDetails == null)
throw IllegalStateException("Missing information for download to complete");
@@ -310,6 +318,10 @@ class VideoDownload {
if(vsource == null)
vsource = VideoHelper.selectBestVideoSource(videoSources, targetPixelCount!!.toInt(), arrayOf())
// ?: 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) {
videoSource = null;
@@ -361,6 +373,12 @@ class VideoDownload {
asource = VideoHelper.selectBestAudioSource(audioSources, arrayOf(), null, targetBitrate)
?: if(videoSource != null ) null
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) {
audioSource = null;
if(!original.video.isUnMuxed || original.video.videoSources.size == 0)
@@ -400,11 +418,13 @@ class VideoDownload {
else audioSource;
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;
}
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;
}
if(subtitleSource != null) {
@@ -1052,8 +1072,8 @@ class VideoDownload {
fun complete() {
Logger.i(TAG, "VideoDownload Complete [${name}]");
val existing = StateDownloads.instance.getCachedVideo(id);
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0) };
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSourceToUse!!, it, audioFileSize ?: 0) };
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0, videoOverrideContainer) };
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) };
val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) };
if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource)
@@ -1082,7 +1102,7 @@ class VideoDownload {
StateDownloads.instance.updateCachedVideo(existing);
}
else {
val newVideo = VideoLocal(videoDetails!!);
val newVideo = VideoLocal(videoDetails!!, OffsetDateTime.now());
if(localVideoSource != null)
newVideo.videoSource.add(localVideoSource);
if(localAudioSource != null)
@@ -1134,7 +1154,7 @@ class VideoDownload {
else if (container.contains("video/x-matroska"))
return "mkv";
else
return "video";
return "video";//throw IllegalStateException("Unknown container: " + container)
}
fun audioContainerToExtension(container: String): String {
@@ -1145,11 +1165,11 @@ class VideoDownload {
else if (container.contains("audio/mp3"))
return "mp3";
else if (container.contains("audio/webm"))
return "webma";
return "webm";
else if (container == "application/vnd.apple.mpegurl")
return "mp4";
return "mp4a";
else
return "audio";
return "audio";// throw IllegalStateException("Unknown container: " + container)
}
fun subtitleContainerToExtension(container: String?): String {
@@ -39,7 +39,7 @@ class VideoExport {
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 a = audioSource;
val s = subtitleSource;
@@ -50,7 +50,7 @@ class VideoExport {
if (s != null) sourceCount++;
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) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container);
val f = downloadRoot.createFile("video/mp4", outputFileName)
@@ -10,7 +10,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.LocalVideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.LocalVideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
@@ -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.SerializedPlatformVideoDetails
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.stores.v2.IStoreItem
import java.io.File
import java.time.OffsetDateTime
@@ -56,7 +57,7 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem {
override val video: IVideoSourceDescriptor get() = if(audioSource.isNotEmpty())
LocalVideoUnMuxedSourceDescriptor(this)
else
LocalVideoMuxedSourceDescriptor(this);
DownloadedVideoMuxedSourceDescriptor(this);
override val preview: IVideoSourceDescriptor? get() = videoSerialized.preview;
override val live: IVideoSource? get() = videoSerialized.live;
@@ -70,14 +71,21 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem {
override val isLive: Boolean get() = videoSerialized.isLive;
override val isShort: Boolean get() = videoSerialized.isShort;
//TODO: Offline subtitles
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.downloadDate = downloadDate;
}
constructor(video: IPlatformVideoDetails, subtitleSources: List<SubtitleRawSource>) {
this.videoSerialized = SerializedPlatformVideoDetails.fromVideo(video, subtitleSources);
downloadDate = OffsetDateTime.now();
}
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.PackageDOMParser
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.V8Package
import com.futo.platformplayer.getOrThrow
@@ -94,7 +95,11 @@ class V8Plugin {
withDependency(PackageBridge(this, config));
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) {
@@ -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?
return when(packageName) {
"DOMParser" -> PackageDOMParser(this)
"Http" -> PackageHttp(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 allowUrls: List<String>;
val packages: List<String>;
val packagesOptional: List<String>;
}
@kotlinx.serialization.Serializable
@@ -13,17 +14,20 @@ class V8PluginConfig : IV8PluginConfig {
override val allowEval: Boolean;
override val allowUrls: List<String>;
override val packages: List<String>;
override val packagesOptional: List<String>;
constructor() {
name = "Unknown";
allowEval = false;
allowUrls = 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.allowEval = allowEval;
this.allowUrls = allowUrls;
this.packages = packages;
this.packagesOptional = packagesOptional;
}
}
@@ -1,8 +1,12 @@
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.V8Property
import com.caoccao.javet.utils.JavetResourceUtils
import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.reference.V8ValueFunction
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.logging.Logger
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.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
@@ -37,6 +42,18 @@ class PackageBridge : V8Package {
_config = config;
_client = plugin.httpClient;
_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();
}
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
fun toast(str: String) {
Logger.i(TAG, "Plugin toast [${_config.name}]: ${str}");
@@ -130,7 +189,44 @@ class PackageBridge : V8Package {
return false;
}
@V8Function
fun getHardwareCodecs(): List<String>{
return getSupportedHardwareMediaCodecs();
}
companion object {
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.logging.Logger
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.net.SocketTimeoutException
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask
import kotlin.concurrent.thread
import kotlin.streams.asSequence
class PackageHttp: V8Package {
@@ -42,6 +46,9 @@ class PackageHttp: V8Package {
override val name: 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) {
_config = config;
@@ -51,6 +58,37 @@ class PackageHttp: V8Package {
_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
fun newClient(withAuth: Boolean): PackageHttpClient {
val httpClient = if(withAuth) _clientAuth.clone() else _client.clone();
@@ -176,8 +214,6 @@ class PackageHttp: V8Package {
obj.set("url", url);
obj.set("code", code);
if(body != null) {
val buffer = runtime.createV8ValueArrayBuffer(body.size);
buffer.fromBytes(body);
obj.set("body", body);
}
obj.set("headers", headers);
@@ -236,16 +272,19 @@ class PackageHttp: V8Package {
//Finalizer
@V8Function
fun execute(): List<IBridgeHttpResponse?> {
return _reqs.parallelStream().map {
return _package.autoParallelPool(_reqs, -1) {
if(it.second.method == "DUMMY")
return@map null;
return@autoParallelPool 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
return@map it.first.request(it.second.method, it.second.url, it.second.headers, it.second.respType);
}
.asSequence()
.toList();
return@autoParallelPool it.first.request(it.second.method, it.second.url, it.second.headers, it.second.respType);
}.map {
if(it.second != null)
throw it.second!!;
else
return@map it.first;
}.toList();
}
}
@@ -439,11 +478,8 @@ class PackageHttp: V8Package {
else {
headers?.forEach { (header, values) ->
val lowerCaseHeader = header.lowercase()
if(lowerCaseHeader == "set-cookie") {
result[lowerCaseHeader] = values.filter{
!it.lowercase().contains("httponly")
};
}
if(lowerCaseHeader == "set-cookie" && !values.any { it.lowercase().contains("httponly") })
result[lowerCaseHeader] = values;
else
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.dp
import com.futo.platformplayer.fixHtmlLinks
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.selectBestImage
@@ -21,6 +20,7 @@ import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.platform.PlatformLinkView
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.toName
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
@@ -134,9 +134,7 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment {
}
}
if(!map.containsKey("Harbor"))
this.context?.let {
map.set("Harbor", polycentricProfile.getHarborUrl(it));
}
map.set("Harbor", polycentricProfile.getHarborUrl());
if (map.isNotEmpty())
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.exceptions.ChannelException
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.states.StateCache
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.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
import com.futo.polycentric.core.PolycentricProfile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.math.max
class ChannelContentsFragment : Fragment(), IChannelTabFragment {
class ChannelContentsFragment(private val subType: String? = null) : Fragment(), IChannelTabFragment {
private var _recyclerResults: RecyclerView? = null;
private var _glmVideo: GridLayoutManager? = null;
private var _loading = false;
@@ -73,9 +73,12 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
if (lastPolycentricProfile != null)
pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile);
if(pager == null)
pager = StatePlatform.instance.getChannelContent(channel.url);
if(pager == null) {
if(subType != null)
pager = StatePlatform.instance.getChannelContent(channel.url, subType);
else
pager = StatePlatform.instance.getChannelContent(channel.url);
}
return pager;
}
@@ -367,6 +370,6 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
companion object {
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.engine.exceptions.ScriptCaptchaRequiredException
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.resolveChannelUrl
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.viewholders.CreatorViewHolder
import com.futo.polycentric.core.PolycentricProfile
class ChannelListFragment : Fragment, IChannelTabFragment {
private var _channels: ArrayList<IPlatformChannel> = arrayListOf();
@@ -8,8 +8,8 @@ import android.widget.TextView
import androidx.fragment.app.Fragment
import com.futo.platformplayer.R
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.polycentric.core.PolycentricProfile
class ChannelMonetizationFragment : Fragment, IChannelTabFragment {
@@ -1,7 +1,7 @@
package com.futo.platformplayer.fragment.channel.tab
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 {
fun setChannel(channel: IPlatformChannel)
@@ -25,6 +25,7 @@ import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
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.ContentType
@@ -41,7 +42,6 @@ import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.selectHighestResolutionImage
import com.futo.platformplayer.states.StatePlatform
@@ -54,29 +54,14 @@ import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.polycentric.core.OwnedClaim
import com.futo.polycentric.core.PublicKey
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
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() {
override val isMainView: Boolean = true
@@ -143,15 +128,14 @@ class ChannelFragment : MainFragment() {
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>
init {
inflater.inflate(R.layout.fragment_channel, this)
_taskLoadPolycentricProfile =
TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>({ fragment.lifecycleScope },
_taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>({ fragment.lifecycleScope },
{ 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> {
Logger.w(TAG, "Failed to load polycentric profile.", it)
}
@@ -237,8 +221,8 @@ class ChannelFragment : MainFragment() {
}
adapter.onAddToWatchLaterClicked.subscribe { content ->
if (content is IPlatformVideo) {
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true)
UIDialogs.toast("Added to watch later\n[${content.name}]")
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true))
UIDialogs.toast("Added to watch later\n[${content.name}]")
}
}
adapter.onUrlClicked.subscribe { url ->
@@ -327,7 +311,7 @@ class ChannelFragment : MainFragment() {
_creatorThumbnail.setThumbnail(parameter.thumbnail, true)
Glide.with(_imageBanner).clear(_imageBanner)
loadPolycentricProfile(parameter.id, parameter.url)
loadPolycentricProfile(parameter.id)
}
_url = parameter.url
@@ -341,7 +325,7 @@ class ChannelFragment : MainFragment() {
_creatorThumbnail.setThumbnail(parameter.channel.thumbnail, true)
Glide.with(_imageBanner).clear(_imageBanner)
loadPolycentricProfile(parameter.channel.id, parameter.channel.url)
loadPolycentricProfile(parameter.channel.id)
}
_url = parameter.channel.url
@@ -358,16 +342,8 @@ class ChannelFragment : MainFragment() {
_tabs.selectTab(_tabs.getTabAt(selectedTabIndex))
}
private fun loadPolycentricProfile(id: PlatformID, url: String) {
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(url, true)
if (cachedPolycentricProfile != null) {
setPolycentricProfile(cachedPolycentricProfile, animate = true)
if (cachedPolycentricProfile.expired) {
_taskLoadPolycentricProfile.run(id)
}
} else {
_taskLoadPolycentricProfile.run(id)
}
private fun loadPolycentricProfile(id: PlatformID) {
_taskLoadPolycentricProfile.run(id)
}
private fun setLoading(isLoading: Boolean) {
@@ -457,6 +433,12 @@ class ChannelFragment : MainFragment() {
_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
).lowercase() else ""
val supportsPlaylists =
StatePlatform.instance.getChannelClient(channel.url).capabilities.hasGetChannelPlaylists
var supportsPlaylists = false;
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
if (supportsPlaylists && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(
ChannelTab.PLAYLISTS.ordinal.toLong()
@@ -521,20 +508,13 @@ class ChannelFragment : MainFragment() {
private fun setPolycentricProfileOr(url: String, or: () -> Unit) {
setPolycentricProfile(null, animate = false)
val cachedProfile = channel?.let { PolycentricCache.instance.getCachedProfile(url) }
if (cachedProfile != null) {
setPolycentricProfile(cachedProfile, animate = false)
} else {
or()
}
or()
}
private fun setPolycentricProfile(
cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean
profile: PolycentricProfile?, animate: Boolean
) {
val dp35 = 35.dp(resources)
val profile = cachedPolycentricProfile?.profile
val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35)?.let {
it.toURLInfoSystemLinkUrl(
profile.system.toProto(), it.process, profile.systemState.servers.toList()
@@ -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.video.IPlatformVideoDetails
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
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.overlays.RepliesOverlay
import com.futo.polycentric.core.PublicKey
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.net.UnknownHostException
@@ -82,8 +82,8 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
};
adapter.onAddToWatchLaterClicked.subscribe(this) {
if(it is IPlatformVideo) {
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true);
UIDialogs.toast("Added to watch later\n[${it.name}]");
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true))
UIDialogs.toast("Added to watch later\n[${it.name}]");
}
};
adapter.onLongPress.subscribe(this) {
@@ -201,11 +201,12 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
protected open fun onContentUrlClicked(url: String, contentType: ContentType) {
when(contentType) {
ContentType.MEDIA -> {
StatePlayer.instance.clearQueue();
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail();
};
ContentType.PLAYLIST -> fragment.navigate<RemotePlaylistFragment>(url);
ContentType.URL -> fragment.navigate<BrowserFragment>(url);
StatePlayer.instance.clearQueue()
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
}
ContentType.PLAYLIST -> fragment.navigate<RemotePlaylistFragment>(url)
ContentType.URL -> fragment.navigate<BrowserFragment>(url)
ContentType.CHANNEL -> fragment.navigate<ChannelFragment>(url)
else -> {};
}
}
@@ -9,7 +9,9 @@ import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.TaskHandler
@@ -17,6 +19,8 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.isHttpUrl
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.FeedStyle
import kotlinx.coroutines.Dispatchers
@@ -82,6 +86,7 @@ class ContentSearchResultsFragment : MainFragment() {
private var _filterValues: HashMap<String, List<String>> = hashMapOf();
private var _enabledClientIds: List<String>? = null;
private var _channelUrl: String? = null;
private var _searchType: SearchType? = null;
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar
@@ -93,7 +98,13 @@ class ContentSearchResultsFragment : MainFragment() {
if (channelUrl != null) {
StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds)
} else {
StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
when (_searchType)
{
SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
SearchType.CREATOR -> StatePlatform.instance.searchChannelsAsContent(query)
SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query)
else -> throw Exception("Search type must be specified")
}
}
})
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
@@ -114,6 +125,7 @@ class ContentSearchResultsFragment : MainFragment() {
if(parameter is SuggestionsFragmentData) {
setQuery(parameter.query, false);
setChannelUrl(parameter.channelUrl, false);
setSearchType(parameter.searchType, false)
fragment.topBar?.apply {
if (this is SearchTopBarFragment) {
@@ -159,8 +171,14 @@ class ContentSearchResultsFragment : MainFragment() {
navigate<RemotePlaylistFragment>(it);
else if(StatePlatform.instance.hasEnabledChannelClient(it))
navigate<ChannelFragment>(it);
else
navigate<VideoDetailFragment>(it);
else {
val url = it;
activity?.let {
close()
if(it is MainActivity)
it.navigate(it.getFragment<VideoDetailFragment>(), url);
}
}
}
else
setQuery(it, true);
@@ -222,6 +240,12 @@ class ContentSearchResultsFragment : MainFragment() {
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() {
loadResults();
}
@@ -244,6 +268,15 @@ class ContentSearchResultsFragment : MainFragment() {
}
}
private fun setSearchType(searchType: SearchType, updateResults: Boolean = true) {
_searchType = searchType
if (updateResults) {
clearResults();
loadResults();
}
}
private fun setSortBy(sortBy: String?, updateResults: Boolean = true) {
_sortBy = sortBy;
@@ -10,6 +10,7 @@ import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.Spinner
import android.widget.TextView
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -26,6 +27,7 @@ class CreatorsFragment : MainFragment() {
private var _overlayContainer: FrameLayout? = null;
private var _containerSearch: FrameLayout? = null;
private var _editSearch: EditText? = null;
private var _textMeta: TextView? = null;
private var _buttonClearSearch: ImageButton? = null
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 buttonClearSearch: ImageButton = view.findViewById(R.id.button_clear_search)
_editSearch = editSearch
_textMeta = view.findViewById(R.id.text_meta);
_buttonClearSearch = buttonClearSearch
buttonClearSearch.setOnClickListener {
editSearch.text.clear()
@@ -41,7 +44,11 @@ class CreatorsFragment : MainFragment() {
_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.onSettings.subscribe { sub -> _overlayContainer?.let { UISlideOverlays.showSubscriptionOptionsOverlay(sub, it) } }
@@ -4,19 +4,31 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.Spinner
import android.widget.TextView
import androidx.core.widget.addTextChangedListener
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.downloads.VideoDownload
import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.services.DownloadService
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.toHumanBytesSize
import com.futo.platformplayer.toHumanDuration
import com.futo.platformplayer.views.AnyInsertedAdapterView
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
import com.futo.platformplayer.views.adapters.viewholders.VideoDownloadViewHolder
@@ -25,6 +37,7 @@ import com.futo.platformplayer.views.items.PlaylistDownloadItem
import com.futo.platformplayer.views.others.ProgressBar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.time.OffsetDateTime
class DownloadsFragment : MainFragment() {
private val TAG = "DownloadsFragment";
@@ -45,6 +58,15 @@ class DownloadsFragment : MainFragment() {
super.onResume()
_view?.reloadUI();
if(StateDownloads.instance.getDownloading().any { it.state == VideoDownload.State.QUEUED } &&
!StateDownloads.instance.getDownloading().any { it.state == VideoDownload.State.DOWNLOADING } &&
Settings.instance.downloads.shouldDownload()) {
Logger.w(TAG, "Detected queued download, while not downloading, attempt recreating service");
StateApp.withContext {
DownloadService.getOrCreateService(it);
}
}
StateDownloads.instance.onDownloadsChanged.subscribe(this) {
lifecycleScope.launch(Dispatchers.Main) {
try {
@@ -92,18 +114,26 @@ class DownloadsFragment : MainFragment() {
private val _listDownloadedHeader: LinearLayout;
private val _listDownloadedMeta: TextView;
private val _listDownloadSearch: EditText;
private val _listDownloaded: AnyInsertedAdapterView<VideoLocal, VideoDownloadViewHolder>;
private var lastDownloads: List<VideoLocal>? = null;
private var ordering = FragmentedStorage.get<StringStorage>("downloads_ordering")
constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) {
inflater.inflate(R.layout.fragment_downloads, this);
_frag = frag;
if(ordering.value.isNullOrBlank())
ordering.value = "nameAsc";
_usageUsed = findViewById(R.id.downloads_usage_used);
_usageAvailable = findViewById(R.id.downloads_usage_available);
_usageProgress = findViewById(R.id.downloads_usage_progress);
_listActiveDownloadsContainer = findViewById(R.id.downloads_active_downloads_container);
_listActiveDownloadsMeta = findViewById(R.id.downloads_active_downloads_meta);
_listDownloadSearch = findViewById(R.id.downloads_search);
_listActiveDownloads = findViewById(R.id.downloads_active_downloads_list);
_listPlaylistsContainer = findViewById(R.id.downloads_playlist_container);
@@ -113,6 +143,31 @@ class DownloadsFragment : MainFragment() {
_listDownloadedHeader = findViewById(R.id.downloads_videos_header);
_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);
};
val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc");
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
when(pos) {
0 -> ordering.setAndSave("nameAsc")
1 -> ordering.setAndSave("nameDesc")
2 -> ordering.setAndSave("downloadDateAsc")
3 -> ordering.setAndSave("downloadDateDesc")
4 -> ordering.setAndSave("releasedAsc")
5 -> ordering.setAndSave("releasedDesc")
else -> ordering.setAndSave("")
}
updateContentFilters()
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
};
spinnerSortBy.setSelection(Math.max(0, options.indexOf(ordering.value)));
_listDownloaded = findViewById<RecyclerView>(R.id.list_downloaded)
.asAnyWithTop(findViewById(R.id.downloads_top)) {
it.onClick.subscribe {
@@ -125,7 +180,6 @@ class DownloadsFragment : MainFragment() {
reloadUI();
}
fun reloadUI() {
val usage = StateDownloads.instance.getTotalUsage(true);
_usageUsed.text = "${usage.usage.toHumanBytesSize()} " + context.getString(R.string.used);
@@ -181,10 +235,32 @@ class DownloadsFragment : MainFragment() {
_listDownloadedHeader.visibility = GONE;
} else {
_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) || it.author.name.contains(_listDownloadSearch.text, true) };
if(!ordering.value.isNullOrEmpty()) {
vidsToReturn = when(ordering.value){
"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;
}
}
}
@@ -3,12 +3,15 @@ package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.content.res.Configuration
import android.graphics.Color
import android.util.DisplayMetrics
import android.view.Display
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.LayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@@ -20,6 +23,7 @@ import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.others.ProgressBar
import com.futo.platformplayer.views.others.TagsView
@@ -28,7 +32,9 @@ import com.futo.platformplayer.views.adapters.InsertedViewHolder
import com.futo.platformplayer.views.announcements.AnnouncementView
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.OffsetDateTime
import kotlin.math.max
@@ -68,6 +74,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private val _scrollListener: RecyclerView.OnScrollListener;
private var _automaticNextPageCounter = 0;
private val _automaticBackoff = arrayOf(0, 500, 1000, 1000, 2000, 5000, 5000, 5000);
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, GridLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>? = null) : super(inflater.context) {
this.fragment = fragment;
@@ -182,29 +189,61 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
val canScroll = if (recyclerData.results.isEmpty()) false else {
val height = resources.displayMetrics.heightPixels;
val layoutManager = recyclerData.layoutManager
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisibleItemPosition != RecyclerView.NO_POSITION) {
val firstVisibleView = layoutManager.findViewByPosition(firstVisibleItemPosition)
val itemHeight = firstVisibleView?.height ?: 0
val occupiedSpace = recyclerData.results.size / recyclerData.layoutManager.spanCount * itemHeight
val recyclerViewHeight = _recyclerResults.height
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage occupiedSpace=$occupiedSpace recyclerViewHeight=$recyclerViewHeight")
occupiedSpace >= recyclerViewHeight
val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null;
val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition();
val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null;
val rows = if(recyclerData.layoutManager is GridLayoutManager) Math.max(1, recyclerData.results.size / recyclerData.layoutManager.spanCount) else 1;
val rowsHeight = (firstVisibleItemView?.height ?: 0) * rows;
if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) {
false;
}
else if (firstVisibleItemView != null && height != null && rowsHeight < height) {
false;
} else {
false
true;
}
}
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter")
if (!canScroll || filteredResults.isEmpty()) {
_automaticNextPageCounter++
if(_automaticNextPageCounter <= 4)
loadNextPage()
if(_automaticNextPageCounter < _automaticBackoff.size) {
if(_automaticNextPageCounter > 0) {
val automaticNextPageCounterSaved = _automaticNextPageCounter;
fragment.lifecycleScope.launch(Dispatchers.Default) {
val backoff = _automaticBackoff[Math.min(_automaticBackoff.size - 1, _automaticNextPageCounter)];
withContext(Dispatchers.Main) {
setLoading(true);
}
delay(backoff.toLong());
if(automaticNextPageCounterSaved == _automaticNextPageCounter) {
withContext(Dispatchers.Main) {
loadNextPage();
}
}
else {
withContext(Dispatchers.Main) {
setLoading(false);
}
}
}
}
else
loadNextPage();
}
} else {
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
_automaticNextPageCounter = 0;
}
}
fun resetAutomaticNextPageCounter(){
_automaticNextPageCounter = 0;
}
protected fun setTextCentered(text: String?) {
_textCentered.text = text;
@@ -5,28 +5,38 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.allViews
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import com.futo.platformplayer.*
import com.futo.platformplayer.UISlideOverlays.Companion.showOrderOverlay
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.IRefreshPager
import com.futo.platformplayer.api.media.structures.IReusablePager
import com.futo.platformplayer.api.media.structures.ReusablePager
import com.futo.platformplayer.api.media.structures.ReusableRefreshPager
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.views.FeedStyle
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.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.InsertedViewHolder
import com.futo.platformplayer.views.announcements.AnnouncementView
import com.futo.platformplayer.views.buttons.BigButton
import kotlinx.coroutines.runBlocking
import java.time.OffsetDateTime
@@ -38,6 +48,12 @@ class HomeFragment : MainFragment() {
private var _view: HomeView? = null;
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
private var _cachedLastPager: IReusablePager<IPlatformContent>? = null
private var _toggleRecent = false;
private var _toggleWatched = false;
private var _togglePluginsDisabled = mutableListOf<String>();
fun reloadFeed() {
_view?.reloadFeed()
@@ -63,7 +79,7 @@ class HomeFragment : MainFragment() {
}
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = HomeView(this, inflater, _cachedRecyclerData);
val view = HomeView(this, inflater, _cachedRecyclerData, _cachedLastPager);
_view = view;
return view;
}
@@ -81,6 +97,7 @@ class HomeFragment : MainFragment() {
val view = _view;
if (view != null) {
_cachedRecyclerData = view.recyclerData;
_cachedLastPager = view.lastPager;
view.cleanup();
_view = null;
}
@@ -90,18 +107,32 @@ class HomeFragment : MainFragment() {
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.home.previewFeedItems);
}
@SuppressLint("ViewConstructor")
class HomeView : ContentFeedView<HomeFragment> {
override val feedStyle: FeedStyle get() = Settings.instance.home.getHomeFeedStyle();
private var _toggleBar: ToggleBar? = null;
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
var lastPager: IReusablePager<IPlatformContent>? = null;
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null, cachedLastPager: IReusablePager<IPlatformContent>? = null) : super(fragment, inflater, cachedRecyclerData) {
lastPager = cachedLastPager
_taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({ fragment.lifecycleScope }, {
StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope)
})
.success { loadedResult(it); }
.success {
val wrappedPager = if(it is IRefreshPager)
ReusableRefreshPager(it);
else
ReusablePager(it);
lastPager = wrappedPager;
resetAutomaticNextPageCounter();
loadedResult(wrappedPager.getWindow());
}
.exception<ScriptCaptchaRequiredException> { }
.exception<ScriptExecutionException> {
Logger.w(ChannelFragment.TAG, "Plugin failure.", it);
@@ -127,6 +158,8 @@ class HomeFragment : MainFragment() {
}, fragment);
};
initializeToolbarContent();
setPreviewsEnabled(Settings.instance.home.previewFeedItems);
showAnnouncementView()
}
@@ -201,13 +234,119 @@ class HomeFragment : MainFragment() {
loadResults();
}
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
return results.filter { !StateMeta.instance.isVideoHidden(it.url) && !StateMeta.instance.isCreatorHidden(it.author.url) };
private val _filterLock = Object();
private var _togglesConfig = FragmentedStorage.get<StringArrayStorage>("home_toggles");
fun initializeToolbarContent() {
if(_toolbarContentView.allViews.any { it is ToggleBar })
_toolbarContentView.removeView(_toolbarContentView.allViews.find { it is ToggleBar });
if(Settings.instance.home.showHomeFilters) {
if (!_togglesConfig.any()) {
_togglesConfig.set("today", "watched", "plugins");
_togglesConfig.save();
}
_toggleBar = ToggleBar(context).apply {
layoutParams =
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
}
synchronized(_filterLock) {
var buttonsPlugins: List<ToggleBar.Toggle> = listOf()
buttonsPlugins = (if (_togglesConfig.contains("plugins"))
(StatePlatform.instance.getEnabledClients()
.filter { it is JSClient && it.enableInHome }
.map { plugin ->
ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) plugin.name else "", plugin.icon, !fragment._togglePluginsDisabled.contains(plugin.id), { view, active ->
var dontSwap = false;
if (active) {
if (fragment._togglePluginsDisabled.contains(plugin.id))
fragment._togglePluginsDisabled.remove(plugin.id);
} else {
if (!fragment._togglePluginsDisabled.contains(plugin.id)) {
val enabledClients = StatePlatform.instance.getEnabledClients();
val availableAfterDisable = enabledClients.count { !fragment._togglePluginsDisabled.contains(it.id) && it.id != plugin.id };
if(availableAfterDisable > 0)
fragment._togglePluginsDisabled.add(plugin.id);
else {
UIDialogs.appToast("Home needs atleast 1 plugin active");
dontSwap = true;
}
}
}
if(!dontSwap)
reloadForFilters();
else {
view.setToggle(!active);
}
}).withTag("plugins")
})
else listOf())
val buttons = (listOf<ToggleBar.Toggle?>(
(if (_togglesConfig.contains("today"))
ToggleBar.Toggle("Today", fragment._toggleRecent) { view, active ->
fragment._toggleRecent = active; reloadForFilters()
}
.withTag("today") else null),
(if (_togglesConfig.contains("watched"))
ToggleBar.Toggle("Unwatched", fragment._toggleWatched) { view, active ->
fragment._toggleWatched = active; reloadForFilters()
}
.withTag("watched") else null),
).filterNotNull() + buttonsPlugins)
.sortedBy { _togglesConfig.indexOf(it.tag ?: "") } ?: listOf()
val buttonSettings = ToggleBar.Toggle("", R.drawable.ic_settings, true, { view, active ->
showOrderOverlay(_overlayContainer,
"Visible home filters",
listOf(
Pair("Plugins", "plugins"),
Pair("Today", "today"),
Pair("Watched", "watched")
),
{
val newArray = it.map { it.toString() }.toTypedArray();
_togglesConfig.set(*(if (newArray.any()) newArray else arrayOf("none")));
_togglesConfig.save();
initializeToolbarContent();
},
"Select which toggles you want to see in order. You can also choose to hide filters in the Grayjay Settings"
);
}).asButton();
val buttonsOrder = (buttons + listOf(buttonSettings)).toTypedArray();
_toggleBar?.setToggles(*buttonsOrder);
}
_toolbarContentView.addView(_toggleBar, 0);
}
}
fun reloadForFilters() {
lastPager?.let { loadedResult(it.getWindow()) };
}
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(fragment._toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25)
return@filter false;
if(fragment._toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0))
return@filter false;
if(fragment._togglePluginsDisabled.any() && it.id.pluginId != null && fragment._togglePluginsDisabled.contains(it.id.pluginId)) {
return@filter false;
}
return@filter true;
};
}
private fun loadResults(withRefetch: Boolean = true) {
setLoading(true);
_taskGetPager.run(true);
_taskGetPager.run(withRefetch);
}
private fun loadedResult(pager : IPager<IPlatformContent>) {
if (pager is EmptyPager<IPlatformContent>) {
@@ -8,6 +8,7 @@ import android.view.ViewGroup
import androidx.core.app.ShareCompat
import androidx.lifecycle.lifecycleScope
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.IPlatformPlaylistDetails
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 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;
editPlaylistOverlay.onOK.subscribe {
val text = nameInput.text;
@@ -146,7 +155,7 @@ class PlaylistFragment : MainFragment() {
setName(it.name);
//TODO: Implement support for pagination
setVideos(it.videos, false);
setVideoCount(it.videos.size);
setMetadata(it.videos.size, it.videos.sumOf { it.duration });
setLoading(false);
}
.exception<Throwable> {
@@ -174,8 +183,9 @@ class PlaylistFragment : MainFragment() {
if (parameter != null) {
setName(parameter.name)
setVideos(parameter.videos, true)
setVideoCount(parameter.videos.size)
setMetadata(parameter.videos.size, parameter.videos.sumOf { it.duration })
setButtonDownloadVisible(true)
setButtonExportVisible(false)
setButtonEditVisible(true)
if (!StatePlaylists.instance.playlistStore.hasItem { it.id == parameter.id }) {
@@ -187,7 +197,7 @@ class PlaylistFragment : MainFragment() {
} else {
setName(null)
setVideos(null, false)
setVideoCount(-1)
setMetadata(-1, -1);
setButtonDownloadVisible(false)
setButtonEditVisible(false)
}
@@ -195,7 +205,7 @@ class PlaylistFragment : MainFragment() {
_playlist = null
_url = parameter.url
setVideoCount(parameter.videoCount)
setMetadata(parameter.videoCount, -1);
setName(parameter.name)
setVideos(null, false)
setButtonDownloadVisible(false)
@@ -208,7 +218,7 @@ class PlaylistFragment : MainFragment() {
setName(null)
setVideos(null, false)
setVideoCount(-1)
setMetadata(-1, -1);
setButtonDownloadVisible(false)
setButtonEditVisible(false)
@@ -316,6 +326,10 @@ class PlaylistFragment : MainFragment() {
playlist.videos = ArrayList(playlist.videos.filter { it != video });
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
}
override fun onVideoOptions(video: IPlatformVideo) {
UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer);
}
override fun onVideoClicked(video: IPlatformVideo) {
val playlist = _playlist;
if (playlist != null) {
@@ -6,12 +6,17 @@ import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.Spinner
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -21,11 +26,15 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.views.SearchView
import com.futo.platformplayer.views.adapters.*
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.google.android.material.appbar.AppBarLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.time.OffsetDateTime
class PlaylistsFragment : MainFragment() {
@@ -65,6 +74,7 @@ class PlaylistsFragment : MainFragment() {
private val _fragment: PlaylistsFragment;
var watchLater: ArrayList<IPlatformVideo> = arrayListOf();
var allPlaylists: ArrayList<Playlist> = arrayListOf();
var playlists: ArrayList<Playlist> = arrayListOf();
private var _appBar: AppBarLayout;
private var _adapterWatchLater: VideoListHorizontalAdapter;
@@ -72,12 +82,20 @@ class PlaylistsFragment : MainFragment() {
private var _layoutWatchlist: ConstraintLayout;
private var _slideUpOverlay: SlideUpMenuOverlay? = null;
private var _listPlaylistsSearch: EditText;
private var _ordering = FragmentedStorage.get<StringStorage>("playlists_ordering")
constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) {
_fragment = fragment;
inflater.inflate(R.layout.fragment_playlists, this);
_listPlaylistsSearch = findViewById(R.id.playlists_search);
watchLater = ArrayList();
playlists = ArrayList();
allPlaylists = ArrayList();
val recyclerWatchLater = findViewById<RecyclerView>(R.id.recycler_watch_later);
@@ -105,6 +123,7 @@ class PlaylistsFragment : MainFragment() {
buttonCreatePlaylist.setOnClickListener {
_slideUpOverlay = UISlideOverlays.showCreatePlaylistOverlay(findViewById<FrameLayout>(R.id.overlay_create_playlist)) {
val playlist = Playlist(it, arrayListOf());
allPlaylists.add(0, playlist);
playlists.add(0, playlist);
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
@@ -120,6 +139,35 @@ class PlaylistsFragment : MainFragment() {
_appBar = findViewById(R.id.app_bar);
_layoutWatchlist = findViewById(R.id.layout_watchlist);
_listPlaylistsSearch.addTextChangedListener {
updatePlaylistsFiltering();
}
val spinnerSortBy: Spinner = findViewById(R.id.spinner_sortby);
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.playlists_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
};
val options = listOf("nameAsc", "nameDesc", "dateEditAsc", "dateEditDesc", "dateCreateAsc", "dateCreateDesc", "datePlayAsc", "datePlayDesc");
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
when(pos) {
0 -> _ordering.setAndSave("nameAsc")
1 -> _ordering.setAndSave("nameDesc")
2 -> _ordering.setAndSave("dateEditAsc")
3 -> _ordering.setAndSave("dateEditDesc")
4 -> _ordering.setAndSave("dateCreateAsc")
5 -> _ordering.setAndSave("dateCreateDesc")
6 -> _ordering.setAndSave("datePlayAsc")
7 -> _ordering.setAndSave("datePlayDesc")
else -> _ordering.setAndSave("")
}
updatePlaylistsFiltering()
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
};
spinnerSortBy.setSelection(Math.max(0, options.indexOf(_ordering.value)));
findViewById<TextView>(R.id.text_view_all).setOnClickListener { _fragment.navigate<WatchLaterFragment>(context.getString(R.string.watch_later)); };
StatePlaylists.instance.onWatchLaterChanged.subscribe(this) {
fragment.lifecycleScope.launch(Dispatchers.Main) {
@@ -134,10 +182,12 @@ class PlaylistsFragment : MainFragment() {
@SuppressLint("NotifyDataSetChanged")
fun onShown() {
allPlaylists.clear();
playlists.clear()
playlists.addAll(
allPlaylists.addAll(
StatePlaylists.instance.getPlaylists().sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) }
);
playlists.addAll(filterPlaylists(allPlaylists));
_adapterPlaylist.notifyDataSetChanged();
updateWatchLater();
@@ -157,6 +207,32 @@ class PlaylistsFragment : MainFragment() {
return false;
}
private fun updatePlaylistsFiltering() {
val toFilter = allPlaylists ?: return;
playlists.clear();
playlists.addAll(filterPlaylists(toFilter));
_adapterPlaylist.notifyDataSetChanged();
}
private fun filterPlaylists(pls: List<Playlist>): List<Playlist> {
var playlistsToReturn = pls;
if(!_listPlaylistsSearch.text.isNullOrEmpty())
playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) };
if(!_ordering.value.isNullOrEmpty()){
playlistsToReturn = when(_ordering.value){
"nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() }
"nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() };
"dateEditAsc" -> playlistsToReturn.sortedBy { it.dateUpdate ?: OffsetDateTime.MAX };
"dateEditDesc" -> playlistsToReturn.sortedByDescending { it.dateUpdate ?: OffsetDateTime.MIN }
"dateCreateAsc" -> playlistsToReturn.sortedBy { it.dateCreation ?: OffsetDateTime.MAX };
"dateCreateDesc" -> playlistsToReturn.sortedByDescending { it.dateCreation ?: OffsetDateTime.MIN }
"datePlayAsc" -> playlistsToReturn.sortedBy { it.datePlayed ?: OffsetDateTime.MAX };
"datePlayDesc" -> playlistsToReturn.sortedByDescending { it.datePlayed ?: OffsetDateTime.MIN }
else -> playlistsToReturn
}
}
return playlistsToReturn;
}
private fun updateWatchLater() {
val watchList = StatePlaylists.instance.getWatchLater();
if (watchList.isNotEmpty()) {
@@ -164,7 +240,7 @@ class PlaylistsFragment : MainFragment() {
_appBar.let { appBar ->
val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams;
layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 230.0f, resources.displayMetrics).toInt();
layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 315.0f, resources.displayMetrics).toInt();
appBar.layoutParams = layoutParams;
}
} else {
@@ -172,7 +248,7 @@ class PlaylistsFragment : MainFragment() {
_appBar.let { appBar ->
val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams;
layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 25.0f, resources.displayMetrics).toInt();
layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 110.0f, resources.displayMetrics).toInt();
appBar.layoutParams = layoutParams;
};
}
@@ -33,10 +33,8 @@ import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.fixHtmlWhitespace
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
import com.futo.platformplayer.states.StateApp
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.comments.AddCommentView
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.pills.PillRatingLikesDislikes
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.Models
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.material.imageview.ShapeableImageView
import com.google.android.material.shape.CornerFamily
@@ -112,7 +111,7 @@ class PostDetailFragment : MainFragment {
private var _isLoading = false;
private var _post: IPlatformPostDetails? = null;
private var _postOverview: IPlatformPost? = null;
private var _polycentricProfile: PolycentricCache.CachedPolycentricProfile? = null;
private var _polycentricProfile: PolycentricProfile? = null;
private var _version = 0;
private var _isRepliesVisible: Boolean = false;
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);
} 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) }
.exception<Throwable> {
Logger.w(TAG, "Failed to load claims.", it);
@@ -274,7 +273,7 @@ class PostDetailFragment : MainFragment {
};
_buttonStore.setOnClickListener {
_polycentricProfile?.profile?.systemState?.store?.let {
_polycentricProfile?.systemState?.store?.let {
try {
val uri = Uri.parse(it);
val intent = Intent(Intent.ACTION_VIEW);
@@ -334,7 +333,7 @@ class PostDetailFragment : MainFragment {
}
try {
val queryReferencesResponse = ApiMethods.getQueryReferences(PolycentricCache.SERVER, ref, null,null,
val queryReferencesResponse = ApiMethods.getQueryReferences(ApiMethods.SERVER, ref, null,null,
arrayListOf(
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(
ContentType.OPINION.value).setValue(
@@ -604,16 +603,8 @@ class PostDetailFragment : MainFragment {
private fun fetchPolycentricProfile() {
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);
_taskLoadPolycentricProfile.run(author.id);
}
}
private fun setChannelMeta(value: IPlatformPost?) {
@@ -639,17 +630,18 @@ class PostDetailFragment : MainFragment {
_repliesOverlay.cleanup();
}
private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
_polycentricProfile = cachedPolycentricProfile;
private fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) {
_polycentricProfile = polycentricProfile;
if (cachedPolycentricProfile?.profile == null) {
val pp = _polycentricProfile;
if (pp == null) {
_layoutMonetization.visibility = View.GONE;
_creatorThumbnail.setHarborAvailable(false, animate, null);
return;
}
_layoutMonetization.visibility = View.VISIBLE;
_creatorThumbnail.setHarborAvailable(true, animate, cachedPolycentricProfile.profile.system.toProto());
_creatorThumbnail.setHarborAvailable(true, animate, pp.system.toProto());
}
private fun fetchPost() {
@@ -237,7 +237,19 @@ class SourceDetailFragment : MainFragment() {
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) {
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
)
);
@@ -256,6 +256,8 @@ class SubscriptionGroupFragment : MainFragment() {
val sub = StateSubscriptions.instance.getSubscription(sub) ?: StateSubscriptions.instance.getSubscriptionOther(sub);
if(sub != null && sub.channel.thumbnail != null) {
g.image = ImageVariable.fromUrl(sub.channel.thumbnail!!);
if(g.image != null)
g.image!!.subscriptionUrl = sub.channel.url;
g.image?.setImageView(_imageGroup);
g.image?.setImageView(_imageGroupBackground);
break;
@@ -18,6 +18,7 @@ import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.exceptions.RateLimitException
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment.SubscriptionsFeedView.FeedFilterSettings
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.models.SubscriptionGroup
@@ -56,6 +57,9 @@ class SubscriptionsFeedFragment : MainFragment() {
private var _group: SubscriptionGroup? = null;
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
private val _filterLock = Object();
private val _filterSettings = FragmentedStorage.get<FeedFilterSettings>("subFeedFilter");
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
_view?.onShown();
@@ -184,8 +188,6 @@ class SubscriptionsFeedFragment : MainFragment() {
return Json.encodeToString(this);
}
}
private val _filterLock = Object();
private val _filterSettings = FragmentedStorage.get<FeedFilterSettings>("subFeedFilter");
private var _bypassRateLimit = false;
private val _lastExceptions: List<Throwable>? = null;
@@ -204,8 +206,10 @@ class SubscriptionsFeedFragment : MainFragment() {
val feed = StateSubscriptions.instance.getFeed(group?.id);
val currentExs = feed?.exceptions ?: listOf();
if(currentExs != _lastExceptions && currentExs.any())
handleExceptions(currentExs);
if(currentExs != _lastExceptions && currentExs.any()) {
handleExceptions(currentExs)
feed?.exceptions = listOf()
}
return@TaskHandler resp;
})
@@ -282,13 +286,18 @@ class SubscriptionsFeedFragment : MainFragment() {
fragment.navigate<SubscriptionGroupFragment>(g);
};
synchronized(_filterLock) {
synchronized(fragment._filterLock) {
_subscriptionBar?.setToggles(
SubscriptionBar.Toggle(context.getString(R.string.videos), _filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), it); },
SubscriptionBar.Toggle(context.getString(R.string.posts), _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); },
SubscriptionBar.Toggle(context.getString(R.string.live), _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); },
SubscriptionBar.Toggle(context.getString(R.string.planned), _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); },
SubscriptionBar.Toggle(context.getString(R.string.watched), _filterSettings.allowWatched) { _filterSettings.allowWatched = it; _filterSettings.save(); loadResults(false); }
SubscriptionBar.Toggle(context.getString(R.string.videos), fragment._filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { view, active ->
toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), active); },
SubscriptionBar.Toggle(context.getString(R.string.posts), fragment._filterSettings.allowContentTypes.contains(ContentType.POST)) { view, active ->
toggleFilterContentType(ContentType.POST, active); },
SubscriptionBar.Toggle(context.getString(R.string.live), fragment._filterSettings.allowLive) { view, active ->
fragment._filterSettings.allowLive = active; fragment._filterSettings.save(); loadResults(false); },
SubscriptionBar.Toggle(context.getString(R.string.planned), fragment._filterSettings.allowPlanned) { view, active ->
fragment._filterSettings.allowPlanned = active; fragment._filterSettings.save(); loadResults(false); },
SubscriptionBar.Toggle(context.getString(R.string.watched), fragment._filterSettings.allowWatched) { view, active ->
fragment._filterSettings.allowWatched = active; fragment._filterSettings.save(); loadResults(false); }
);
}
@@ -299,13 +308,13 @@ class SubscriptionsFeedFragment : MainFragment() {
toggleFilterContentType(contentType, isTrue);
}
private fun toggleFilterContentType(contentType: ContentType, isTrue: Boolean) {
synchronized(_filterLock) {
synchronized(fragment._filterLock) {
if(!isTrue) {
_filterSettings.allowContentTypes.remove(contentType);
} else if(!_filterSettings.allowContentTypes.contains(contentType)) {
_filterSettings.allowContentTypes.add(contentType)
fragment._filterSettings.allowContentTypes.remove(contentType);
} else if(!fragment._filterSettings.allowContentTypes.contains(contentType)) {
fragment._filterSettings.allowContentTypes.add(contentType)
}
_filterSettings.save();
fragment._filterSettings.save();
};
if(Settings.instance.subscriptions.fetchOnTabOpen) { //TODO: Do this different, temporary workaround
loadResults(false);
@@ -318,9 +327,9 @@ class SubscriptionsFeedFragment : MainFragment() {
val nowSoon = OffsetDateTime.now().plusMinutes(5);
val filterGroup = subGroup;
return results.filter {
val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType);
val allowedContentType = fragment._filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType);
if(it is IPlatformVideo && it.duration > 0 && !_filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration))
if(it is IPlatformVideo && it.duration > 0 && !fragment._filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration))
return@filter false;
//TODO: Check against a sub cache
@@ -329,11 +338,11 @@ class SubscriptionsFeedFragment : MainFragment() {
if(it.datetime?.isAfter(nowSoon) == true) {
if(!_filterSettings.allowPlanned)
if(!fragment._filterSettings.allowPlanned)
return@filter false;
}
if(_filterSettings.allowLive) { //If allowLive, always show live
if(fragment._filterSettings.allowLive) { //If allowLive, always show live
if(it is IPlatformVideo && it.isLive)
return@filter true;
}
@@ -9,6 +9,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.*
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.logging.Logger
@@ -17,6 +18,8 @@ import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.SearchHistoryStorage
import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter
import com.futo.platformplayer.views.others.RadioGroupView
import com.futo.platformplayer.views.others.TagsView
data class SuggestionsFragmentData(val query: String, val searchType: SearchType, val channelUrl: String? = null);
@@ -27,6 +30,7 @@ class SuggestionsFragment : MainFragment {
private var _recyclerSuggestions: RecyclerView? = null;
private var _llmSuggestions: LinearLayoutManager? = null;
private var _radioGroupView: RadioGroupView? = null;
private val _suggestions: ArrayList<String> = ArrayList();
private var _query: String? = null;
private var _searchType: SearchType = SearchType.VIDEO;
@@ -48,14 +52,7 @@ class SuggestionsFragment : MainFragment {
_adapterSuggestions.onClicked.subscribe { suggestion ->
val storage = FragmentedStorage.get<SearchHistoryStorage>();
storage.add(suggestion);
if (_searchType == SearchType.CREATOR) {
navigate<CreatorSearchResultsFragment>(suggestion);
} else if (_searchType == SearchType.PLAYLIST) {
navigate<PlaylistSearchResultsFragment>(suggestion);
} else {
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, SearchType.VIDEO, _channelUrl));
}
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, _searchType, _channelUrl));
}
_adapterSuggestions.onRemove.subscribe { suggestion ->
val index = _suggestions.indexOf(suggestion);
@@ -79,6 +76,15 @@ class SuggestionsFragment : MainFragment {
recyclerSuggestions.adapter = _adapterSuggestions;
_recyclerSuggestions = recyclerSuggestions;
_radioGroupView = view.findViewById<RadioGroupView>(R.id.radio_group).apply {
onSelectedChange.subscribe {
if (it.size != 1)
_searchType = SearchType.VIDEO
else
_searchType = (it[0] ?: SearchType.VIDEO) as SearchType
}
}
loadSuggestions();
return view;
}
@@ -109,25 +115,27 @@ class SuggestionsFragment : MainFragment {
_channelUrl = null;
}
_radioGroupView?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true)
topBar?.apply {
if (this is SearchTopBarFragment) {
onSearch.subscribe(this) {
if (_searchType == SearchType.CREATOR) {
navigate<CreatorSearchResultsFragment>(it);
} else if (_searchType == SearchType.PLAYLIST) {
navigate<PlaylistSearchResultsFragment>(it);
} else {
if(it.isHttpUrl()) {
if(StatePlatform.instance.hasEnabledPlaylistClient(it))
navigate<RemotePlaylistFragment>(it);
else if(StatePlatform.instance.hasEnabledChannelClient(it))
navigate<ChannelFragment>(it);
else
navigate<VideoDetailFragment>(it);
if(it.isHttpUrl()) {
if(StatePlatform.instance.hasEnabledPlaylistClient(it))
navigate<RemotePlaylistFragment>(it);
else if(StatePlatform.instance.hasEnabledChannelClient(it))
navigate<ChannelFragment>(it);
else {
val url = it;
activity?.let {
close()
if(it is MainActivity)
it.navigate(it.getFragment<VideoDetailFragment>(), url);
}
}
else
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
}
else
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, _searchType, _channelUrl));
};
onTextChange.subscribe(this) {
@@ -189,6 +197,7 @@ class SuggestionsFragment : MainFragment {
super.onDestroyMainView();
_getSuggestions.onError.clear();
_recyclerSuggestions = null;
_radioGroupView = null
}
override fun onDestroy() {
@@ -151,6 +151,7 @@ class TutorialFragment : MainFragment() {
override val rating: IRating = RatingLikes(-1)
override val viewCount: Long = -1
override val video: IVideoSourceDescriptor = TutorialVideoSourceDescriptor(videoUrl, duration, width, height)
override val isShort: Boolean = false;
override fun getComments(client: IPlatformClient): IPager<IPlatformComment> {
return EmptyPager()
}
@@ -11,12 +11,14 @@ import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.OrientationEventListener
import android.view.Surface
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import android.view.WindowInsetsController
import android.view.WindowManager
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.core.view.ViewCompat.getDisplay
import androidx.core.view.WindowCompat
import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R
@@ -37,7 +39,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.min
//region Fragment
@UnstableApi
@@ -99,10 +101,7 @@ class VideoDetailFragment() : MainFragment() {
}
private fun isSmallWindow(): Boolean {
return min(
resources.configuration.screenWidthDp,
resources.configuration.screenHeightDp
) < resources.getInteger(R.integer.column_width_dp) * 2
return resources.configuration.smallestScreenWidthDp < resources.getInteger(R.integer.column_width_dp) * 2
}
private fun isAutoRotateEnabled(): Boolean {
@@ -122,6 +121,7 @@ class VideoDetailFragment() : MainFragment() {
isSmallWindow
&& newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE
&& !isFullscreen
&& !isInPictureInPicture
&& state == State.MAXIMIZED
) {
_viewDetail?.setFullscreen(true)
@@ -158,6 +158,8 @@ class VideoDetailFragment() : MainFragment() {
) {
_viewDetail?.setFullscreen(true)
}
updateOrientation()
}
fun updateOrientation() {
@@ -167,14 +169,18 @@ class VideoDetailFragment() : MainFragment() {
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 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
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
@@ -201,7 +207,37 @@ class VideoDetailFragment() : MainFragment() {
} else if (rotationLock) {
_portraitOrientationListener?.disableListener()
_landscapeOrientationListener?.disableListener()
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED
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 {
_portraitOrientationListener?.disableListener()
_landscapeOrientationListener?.disableListener()
@@ -94,12 +94,10 @@ import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.fixHtmlLinks
import com.futo.platformplayer.fixHtmlWhitespace
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.getNowDiffSeconds
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.AnnouncementType
@@ -134,6 +132,7 @@ import com.futo.platformplayer.views.behavior.TouchInterceptFrameLayout
import com.futo.platformplayer.views.casting.CastView
import com.futo.platformplayer.views.comments.AddCommentView
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.ChaptersOverlay
import com.futo.platformplayer.views.overlays.DescriptionOverlay
import com.futo.platformplayer.views.overlays.LiveChatOverlay
import com.futo.platformplayer.views.overlays.QueueEditorOverlay
@@ -149,6 +148,7 @@ import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
import com.futo.platformplayer.views.pills.RoundButton
import com.futo.platformplayer.views.pills.RoundButtonGroup
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.segments.ChaptersList
import com.futo.platformplayer.views.segments.CommentsList
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.platformplayer.views.video.FutoVideoPlayer
@@ -158,6 +158,8 @@ import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Models
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.google.protobuf.ByteString
import kotlinx.coroutines.Dispatchers
@@ -171,7 +173,6 @@ import kotlinx.coroutines.withContext
import userpackage.Protocol
import java.time.OffsetDateTime
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToLong
@UnstableApi
@@ -196,6 +197,8 @@ class VideoDetailView : ConstraintLayout {
private var _liveChat: LiveChatManager? = null;
private var _videoResumePositionMilliseconds : Long = 0L;
private var _chapters: List<IChapter>? = null;
private val _player: FutoVideoPlayer;
private val _cast: CastView;
private val _playerProgress: PlayerControlView;
@@ -264,6 +267,7 @@ class VideoDetailView : ConstraintLayout {
private val _container_content_liveChat: LiveChatOverlay;
private val _container_content_browser: WebviewOverlay;
private val _container_content_support: SupportOverlay;
private val _container_content_chapters: ChaptersOverlay;
private var _container_content_current: View;
@@ -295,7 +299,7 @@ class VideoDetailView : ConstraintLayout {
private set;
private var _historicalPosition: Long = 0;
private var _commentsCount = 0;
private var _polycentricProfile: PolycentricCache.CachedPolycentricProfile? = null;
private var _polycentricProfile: PolycentricProfile? = null;
private var _slideUpOverlay: SlideUpMenuOverlay? = null;
private var _autoplayVideo: IPlatformVideo? = null
@@ -375,6 +379,7 @@ class VideoDetailView : ConstraintLayout {
_container_content_liveChat = findViewById(R.id.videodetail_container_livechat);
_container_content_support = findViewById(R.id.videodetail_container_support);
_container_content_browser = findViewById(R.id.videodetail_container_webview)
_container_content_chapters = findViewById(R.id.videodetail_container_chapters);
_addCommentView = findViewById(R.id.add_comment_view);
_commentsList = findViewById(R.id.comments_list);
@@ -399,6 +404,10 @@ class VideoDetailView : ConstraintLayout {
_monetization = findViewById(R.id.monetization);
_player.attachPlayer();
_player.onChapterClicked.subscribe {
showChaptersUI();
};
_buttonSubscribe.onSubscribed.subscribe {
_slideUpOverlay = UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
@@ -410,12 +419,12 @@ class VideoDetailView : ConstraintLayout {
};
_monetization.onSupportTap.subscribe {
_container_content_support.setPolycentricProfile(_polycentricProfile?.profile);
_container_content_support.setPolycentricProfile(_polycentricProfile);
switchContentView(_container_content_support);
};
_monetization.onStoreTap.subscribe {
_polycentricProfile?.profile?.systemState?.store?.let {
_polycentricProfile?.systemState?.store?.let {
try {
val uri = Uri.parse(it);
val intent = Intent(Intent.ACTION_VIEW);
@@ -580,6 +589,14 @@ class VideoDetailView : ConstraintLayout {
_minimize_title.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 {
if (StateCasting.instance.activeDevice == null) {
handlePlayChanged(it);
@@ -649,18 +666,9 @@ class VideoDetailView : ConstraintLayout {
};
var hadDevice = false;
StateSync.instance.deviceUpdatedOrAdded.subscribe(this) { id, session ->
val hasDevice = StateSync.instance.hasAtLeastOneOnlineDevice();
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) {
val devicesChanged = { id: String ->
val hasDevice = StateSync.instance.hasAuthorizedDevice();
if (hasDevice != hadDevice) {
hadDevice = hasDevice;
fragment.lifecycleScope.launch(Dispatchers.Main) {
updateMoreButtons();
@@ -668,6 +676,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.onPlayReceived.subscribe(this) { handlePlay() };
MediaControlReceiver.onPauseReceived.subscribe(this) { handlePause() };
@@ -682,9 +693,17 @@ class VideoDetailView : ConstraintLayout {
_container_content_description.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_liveChat.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_queue.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_queue.onOptions.subscribe {
UISlideOverlays.showVideoOptionsOverlay(it, _overlayContainer);
}
_container_content_replies.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_support.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_browser.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_chapters.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_chapters.onClick.subscribe {
handleSeek(it.timeStart.toLong() * 1000);
}
_description_viewMore.setOnClickListener {
switchContentView(_container_content_description);
@@ -851,6 +870,22 @@ class VideoDetailView : ConstraintLayout {
_cast.stopAllGestures();
}
fun showChaptersUI(){
video?.let {
try {
_chapters?.let {
if(it.size == 0)
return@let;
_container_content_chapters.setChapters(_chapters);
switchContentView(_container_content_chapters);
}
}
catch(ex: Throwable) {
}
}
}
fun updateMoreButtons() {
val isLimitedVersion = video?.url != null && StatePlatform.instance.getContentClientOrNull(video!!.url)?.let {
if (it is JSClient)
@@ -864,6 +899,13 @@ class VideoDetailView : ConstraintLayout {
};
}
},
_chapters?.let {
if(it != null && it.size > 0)
RoundButton(context, R.drawable.ic_list, "Chapters", TAG_CHAPTERS) {
showChaptersUI();
}
else null
},
if(video?.isLive ?: false)
RoundButton(context, R.drawable.ic_chat, context.getString(R.string.live_chat), TAG_LIVECHAT) {
video?.let {
@@ -876,22 +918,20 @@ class VideoDetailView : ConstraintLayout {
}
_slideUpOverlay?.hide();
} else null,
if(!isLimitedVersion)
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.background), TAG_BACKGROUND) {
if(!allowBackground) {
_player.switchToAudioMode();
allowBackground = true;
it.text.text = resources.getString(R.string.background_revert);
}
else {
_player.switchToVideoMode();
allowBackground = false;
it.text.text = resources.getString(R.string.background);
}
_slideUpOverlay?.hide();
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) {
if (!allowBackground) {
_player.switchToAudioMode();
allowBackground = true;
it.text.text = resources.getString(R.string.background_revert);
} else {
_player.switchToVideoMode();
allowBackground = false;
it.text.text = resources.getString(R.string.background);
}
_slideUpOverlay?.hide();
}
else null,
if(!isLimitedVersion)
if(!isLimitedVersion && !(video?.isLive ?: false))
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
video?.let {
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
@@ -922,18 +962,25 @@ class VideoDetailView : ConstraintLayout {
};
_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) {
val devices = StateSync.instance.getSessions();
val devices = StateSync.instance.getAuthorizedSessions();
val videoToSend = video ?: return@RoundButton;
if(devices.size > 1) {
//not implemented
}
else if(devices.size == 1){
} else if(devices.size == 1){
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) {
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 +1012,7 @@ class VideoDetailView : ConstraintLayout {
throw IllegalStateException("Expected media content, found ${video.contentType}");
withContext(Dispatchers.Main) {
_videoResumePositionMilliseconds = _player.position
setVideoDetails(video);
}
}
@@ -1229,16 +1277,8 @@ class VideoDetailView : ConstraintLayout {
_creatorThumbnail.setThumbnail(video.author.thumbnail, false);
_channelName.text = video.author.name;
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(video.author.url, true);
if (cachedPolycentricProfile != null) {
setPolycentricProfile(cachedPolycentricProfile, animate = false);
if (cachedPolycentricProfile.expired) {
_taskLoadPolycentricProfile.run(video.author.id);
}
} else {
setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(video.author.id);
}
setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(video.author.id);
_player.clear();
@@ -1267,8 +1307,6 @@ class VideoDetailView : ConstraintLayout {
@OptIn(ExperimentalCoroutinesApi::class)
fun setVideoDetails(videoDetail: IPlatformVideoDetails, newVideo: Boolean = false) {
Logger.i(TAG, "setVideoDetails (${videoDetail.name})")
_didTriggerDatasourceErrroCount = 0;
_didTriggerDatasourceError = false;
_autoplayVideo = null
Logger.i(TAG, "Autoplay video cleared (setVideoDetails)")
@@ -1279,6 +1317,10 @@ class VideoDetailView : ConstraintLayout {
_lastVideoSource = null;
_lastAudioSource = 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())
@@ -1339,10 +1381,12 @@ class VideoDetailView : ConstraintLayout {
val chapters = null ?: StatePlatform.instance.getContentChapters(video.url);
_player.setChapters(chapters);
_cast.setChapters(chapters);
_chapters = _player.getChapters();
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to get chapters", ex);
_player.setChapters(null);
_cast.setChapters(null);
_chapters = null;
/*withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to get chapters\n" + ex.message);
@@ -1381,6 +1425,10 @@ class VideoDetailView : ConstraintLayout {
);
}
}
fragment.lifecycleScope.launch(Dispatchers.Main) {
updateMoreButtons();
}
};
}
@@ -1396,11 +1444,8 @@ class VideoDetailView : ConstraintLayout {
setTabIndex(2, true)
} else {
when (Settings.instance.comments.defaultCommentSection) {
0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex(
0,
true
) else setTabIndex(1, true);
1 -> setTabIndex(1, true);
0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex(0, true) else setTabIndex(1, true)
1 -> setTabIndex(1, true)
2 -> setTabIndex(StateMeta.instance.getLastCommentSection(), true)
}
}
@@ -1438,16 +1483,8 @@ class VideoDetailView : ConstraintLayout {
_buttonSubscribe.setSubscribeChannel(video.author.url);
setDescription(video.description.fixHtmlLinks());
_creatorThumbnail.setThumbnail(video.author.thumbnail, false);
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);
}
setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(video.author.id);
_platform.setPlatformFromClientID(video.id.pluginId);
val subTitleSegments: ArrayList<String> = ArrayList();
@@ -1476,7 +1513,7 @@ class VideoDetailView : ConstraintLayout {
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
val queryReferencesResponse = ApiMethods.getQueryReferences(
PolycentricCache.SERVER, ref, null, null,
ApiMethods.SERVER, ref, null, null,
arrayListOf(
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
.setFromType(ContentType.OPINION.value).setValue(
@@ -1492,10 +1529,8 @@ class VideoDetailView : ConstraintLayout {
val likes = queryReferencesResponse.countsList[0];
val dislikes = queryReferencesResponse.countsList[1];
val hasLiked =
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 hasLiked = 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*/;
withContext(Dispatchers.Main) {
_rating.visibility = View.VISIBLE;
@@ -1833,7 +1868,7 @@ class VideoDetailView : ConstraintLayout {
}
}
private var _didTriggerDatasourceErrroCount = 0;
private var _didTriggerDatasourceErrorCount = 0;
private var _didTriggerDatasourceError = false;
private fun onDataSourceError(exception: Throwable) {
Logger.e(TAG, "onDataSourceError", exception);
@@ -1843,32 +1878,53 @@ class VideoDetailView : ConstraintLayout {
return;
val config = currentVideo.sourceConfig;
if(_didTriggerDatasourceErrroCount <= 3) {
if(_didTriggerDatasourceErrorCount <= 3) {
_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;
fragment.lifecycleScope.launch(Dispatchers.IO) {
val newDetails = StatePlatform.instance.getContentDetails(currentVideo.url, true).await();
val previousVideoSource = _lastVideoSource;
val previousAudioSource = _lastAudioSource;
try {
val newDetails = StatePlatform.instance.getContentDetails(currentVideo.url, true).await();
val previousVideoSource = _lastVideoSource;
val previousAudioSource = _lastAudioSource;
if(newDetails is IPlatformVideoDetails) {
val newVideoSource = if(previousVideoSource != null)
VideoHelper.selectBestVideoSource(newDetails.video, previousVideoSource.height * previousVideoSource.width, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS);
else null;
val newAudioSource = if(previousAudioSource != null)
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);
if (newDetails is IPlatformVideoDetails) {
val newVideoSource = if (previousVideoSource != null)
VideoHelper.selectBestVideoSource(
newDetails.video,
previousVideoSource.height * previousVideoSource.width,
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
);
else null;
val newAudioSource = if (previousAudioSource != null)
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, 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,
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),
@@ -1900,13 +1956,45 @@ class VideoDetailView : ConstraintLayout {
return super.onInterceptTouchEvent(ev);
}
//Actions
private fun showVideoSettings() {
Logger.i(TAG, "showVideoSettings")
_overlay_quality_selector?.selectOption("video", _lastVideoSource);
_overlay_quality_selector?.selectOption("audio", _lastAudioSource);
_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
_overlay_quality_selector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" }?.let {
(it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString())
@@ -2080,17 +2168,15 @@ class VideoDetailView : ConstraintLayout {
call = { handleSelectSubtitleTrack(it) })
}.toList().toTypedArray())
else null,
if(liveStreamVideoFormats?.isEmpty() == false)
SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video",
*liveStreamVideoFormats
.map {
SlideUpMenuItem(this.context,
R.drawable.ic_movie,
it.label ?: it.containerMimeType ?: it.bitrate.toString(),
"${it.width}x${it.height}",
tag = it,
call = { _player.selectVideoTrack(it.height) });
}.toList().toTypedArray())
if (liveStreamVideoFormats?.isEmpty() == false) SlideUpMenuGroup(
this.context, context.getString(R.string.stream_video), "video", (listOf(
SlideUpMenuItem(this.context, R.drawable.ic_movie, "Auto", tag = "auto", call = { _player.selectVideoTrack(-1) })
) + (liveStreamVideoFormats.map {
SlideUpMenuItem(this.context, R.drawable.ic_movie, it.label
?: it.containerMimeType
?: it.bitrate.toString(), "${it.width}x${it.height}", tag = it, call = { _player.selectVideoTrack(it.height) });
}))
)
else null,
if(liveStreamAudioFormats?.isEmpty() == false)
SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio",
@@ -2384,8 +2470,13 @@ class VideoDetailView : ConstraintLayout {
}
fun isLandscapeVideo(): Boolean? {
val videoSourceWidth = _player.exoPlayer?.player?.videoSize?.width
val videoSourceHeight = _player.exoPlayer?.player?.videoSize?.height
var videoSourceWidth = _player.exoPlayer?.player?.videoSize?.width
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){
null
@@ -2557,13 +2648,21 @@ class VideoDetailView : ConstraintLayout {
}
onChannelClicked.subscribe {
fragment.navigate<ChannelFragment>(it)
if(it.url.isNotBlank())
fragment.navigate<ChannelFragment>(it)
else
UIDialogs.appToast("No author url present");
}
onAddToWatchLaterClicked.subscribe(this) {
if(it is IPlatformVideo) {
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true);
UIDialogs.toast("Added to watch later\n[${it.name}]");
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true))
UIDialogs.toast("Added to watch later\n[${it.name}]");
}
}
onAddToQueueClicked.subscribe(this) {
if(it is IPlatformVideo) {
StatePlayer.instance.addToQueue(it);
}
}
})
@@ -2735,13 +2834,12 @@ class VideoDetailView : ConstraintLayout {
}
}
private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
_polycentricProfile = cachedPolycentricProfile;
private fun setPolycentricProfile(profile: PolycentricProfile?, animate: Boolean) {
_polycentricProfile = profile
val dp_35 = 35.dp(context.resources)
val profile = cachedPolycentricProfile?.profile;
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_35 * dp_35)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) }
if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, animate);
@@ -2750,12 +2848,12 @@ class VideoDetailView : ConstraintLayout {
_creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
}
val username = cachedPolycentricProfile?.profile?.systemState?.username
val username = profile?.systemState?.username
if (username != null) {
_channelName.text = username
}
_monetization.setPolycentricProfile(cachedPolycentricProfile);
_monetization.setPolycentricProfile(profile);
}
fun setProgressBarOverlayed(isOverlayed: Boolean?) {
@@ -2943,7 +3041,7 @@ class VideoDetailView : ConstraintLayout {
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) }
.exception<Throwable> {
Logger.w(TAG, "Failed to load claims.", it);
@@ -3029,6 +3127,7 @@ class VideoDetailView : ConstraintLayout {
const val TAG_SHARE = "share";
const val TAG_OVERLAY = "overlay";
const val TAG_LIVECHAT = "livechat";
const val TAG_CHAPTERS = "chapters";
const val TAG_OPEN = "open";
const val TAG_SEND_TO_DEVICE = "send_to_device";
const val TAG_MORE = "MORE";

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