Compare commits

..

292 commits
263 ... master

Author SHA1 Message Date
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
Kai DeLorenzo
f55a7f0a7b Merge branch 'detect-system-setting-changes' into 'master'
detect system auto rotate setting changes

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

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

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

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

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

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

See merge request videostreaming/grayjay!56
2024-12-12 12:56:21 +00:00
Kai
ef0ecf249a
update rotation settings 2024-12-11 14:38:58 -06:00
Kelvin
4981617f7a Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-12-11 20:24:17 +01:00
Kelvin
2070bc7007 Refs 2024-12-11 20:24:09 +01:00
Kai DeLorenzo
231d2461b3 Merge branch 'incorrect-number-of-columns-bug' into 'master'
fix the calculation that incorrectly sets the number of columns to display

See merge request videostreaming/grayjay!54
2024-12-11 17:48:17 +00:00
Kai DeLorenzo
3b457f87c4 Merge branch 'fix-fullscreen-from-pip' into 'master'
prevent going into full screen when entering pip mode

See merge request videostreaming/grayjay!55
2024-12-11 17:48:00 +00:00
Koen J
de3ced4d3c Intent class should be MediaButtonReceiver. 2024-12-11 10:41:36 +01:00
Koen J
891777e89e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-12-11 10:36:13 +01:00
Koen J
287239dd1c Added media button receiver. 2024-12-11 10:36:02 +01:00
Kelvin
7cdded8fd7 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-12-10 22:35:46 +01:00
Kelvin
8c9d045e1d Download playlist fix for videos without audio file 2024-12-10 22:35:38 +01:00
Kai
620f5a0459
prevent going into full screen when entering pip mode 2024-12-10 11:30:48 -06:00
Koen
178d874ba0 Merge branch 'spinner-block-player' into 'master'
Spinner block player go full screen

See merge request videostreaming/grayjay!53
2024-12-10 16:18:55 +00:00
Koen
d44f30c8a6 Merge branch 'creator-filter-clear' into 'master'
enable creator filter clear

See merge request videostreaming/grayjay!52
2024-12-10 15:56:42 +00:00
Koen J
ce66937429 Offline playback toast now doesn't show more than once every 5 seconds. 2024-12-10 14:01:34 +01:00
Koen J
9823337375 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-12-10 13:54:29 +01:00
Koen J
11f5f0dfe1 Fixed comment character counter. 2024-12-10 13:54:02 +01:00
Koen J
e1882f19e8 Comment close now requires confirmation. Fixed comment character counter. 2024-12-10 13:52:24 +01:00
Koen J
6a8b9f06c2 Comment close now requires confirmation. 2024-12-10 13:52:02 +01:00
Koen J
752fc8787d Fixed link scrolling behaviour. 2024-12-10 13:29:09 +01:00
Koen J
90a1cd8280 Placing reply comments works again. 2024-12-10 13:07:03 +01:00
Koen J
aa570ac29d Updated submodules. 2024-12-10 12:47:18 +01:00
Kai
fb7b6363f9
added multi column support for channels 2024-12-09 18:00:24 -06:00
Kai
23afe7994c
fix the calculation that incorrectly sets the number of columns to display 2024-12-08 16:47:13 -06:00
Kai
7557e6f6ba
prevent going full screen before the video has loaded 2024-12-07 11:32:28 -06:00
Kai
86b6938911
added video detail check 2024-12-07 11:09:48 -06:00
Kai
8f30a45fa8
enable creator filter clear 2024-12-06 11:13:29 -06:00
Koen J
7c9e9d5f52 Should not crash app when StateSync fails to bind. 2024-12-06 17:49:18 +01:00
Koen J
4066ce73a8 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-12-06 14:55:10 +01:00
Koen J
b5722dba1a MainActivity should be singleInstance. 2024-12-06 14:54:57 +01:00
Koen
81765ecafc Update deploy-playstore.sh 2024-12-06 10:54:36 +00:00
Kelvin
84b42e9d19 refs 2024-12-05 17:08:30 +01:00
Koen J
ed319a0e5f Fixed invalid padding. 2024-12-05 17:03:45 +01:00
Koen J
dd55d10194 Crashfix. 2024-12-05 17:00:23 +01:00
Koen J
2084b46090 Possible fix for sub bar distance. 2024-12-05 16:58:35 +01:00
Koen J
53443a6cf2 Only show announcements on subscriptions if home is not enabled. Add margin top to subscriptions. 2024-12-05 16:51:56 +01:00
Koen J
92715b5642 Fixed crash due to AnnouncementView. 2024-12-05 16:40:05 +01:00
Kelvin
6166392515 Polycentric disk caching 2024-12-04 18:20:40 +01:00
Kelvin
49d0dead7d Fix playlist wrong id for local conversion, refs 2024-12-04 01:31:53 +01:00
Kelvin
6f004830ff Fix directly opening playlists urls in wrong ui, causing wrong thread 2024-12-02 16:23:30 +01:00
Kelvin
e2e5e36bad Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-11-28 20:12:58 +01:00
Kelvin
f267d264d3 Refs 2024-11-28 20:12:50 +01:00
Kelvin
be1a77bfd7 Merge branch 'drm-revamp' into 'master'
Drm revamp

See merge request videostreaming/grayjay!51
2024-11-28 19:10:09 +00:00
Kai DeLorenzo
41a980e826
rename licenseExecutor to licenseRequestExecutor 2024-11-28 13:53:01 -05:00
Kai DeLorenzo
09c09f3d64
switch to executor structure 2024-11-28 13:05:53 -05:00
Kelvin
2404399ec5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-11-28 16:24:10 +01:00
Kelvin
b45d4c0557 Support for per-comment lazy loading for Polycentric 2024-11-28 16:24:01 +01:00
Kai DeLorenzo
a41b138d3c
source.js mapping 2024-11-26 18:54:47 -05:00
Kai DeLorenzo
1e46949dd6
added dash widevine support and made audio url widevine more flexible 2024-11-26 18:52:15 -05:00
Kai DeLorenzo
3ed2c1ba5d Merge branch 'fix-audio-only-playback' into 'master'
Fix audio only playback

See merge request videostreaming/grayjay!50
2024-11-26 21:01:00 +00:00
Kai DeLorenzo
809b99c9c9
fix null pointer exception 2024-11-26 16:00:13 -05:00
Kai DeLorenzo
4d3acdb5fb Merge branch 'landscape-fixes' into 'master'
Fix black bars on top and bottom of videos

See merge request videostreaming/grayjay!49
2024-11-26 19:06:39 +00:00
Kai DeLorenzo
ca9e321ef2
lower minimum video player height 2024-11-26 14:00:35 -05:00
Kelvin
da27517fcf Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-11-26 18:47:40 +01:00
Kelvin
192df0a3b8 Prevent dup history broadcasts 2024-11-26 18:47:33 +01:00
Koen J
a965003a9d Fix gray line in video player. Thank you @michael-oberpriller 2024-11-26 18:33:25 +01:00
Koen J
9ea26c821f Updated submodules 2024-11-26 18:10:44 +01:00
Kelvin
14b699485a Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-11-25 17:10:00 +01:00
Kelvin
1684edc43f refs, ui dialog alignment 2024-11-25 17:09:50 +01:00
Kai DeLorenzo
580c4418b9 Merge branch 'landscape' into 'master'
Landscape

See merge request videostreaming/grayjay!34
2024-11-25 15:45:04 +00:00
Kai DeLorenzo
4a65fc2358
document empty override 2024-11-25 10:33:05 -05:00
Kai DeLorenzo
71ba131fb3
subscriptions feed announcements fix 2024-11-23 17:55:01 -05:00
Kai DeLorenzo
9693b50719
koen code review fixes 2024-11-23 17:42:23 -05:00
Kelvin
102e2c54bb Working watchlater sync 2024-11-22 21:51:06 +01:00
Kelvin
e989590c08 Merge 2024-11-22 20:35:01 +01:00
Kelvin
6cee33b449 WIP Watchlater sync 2024-11-22 20:33:44 +01:00
Koen J
f32498a444 Implemented session id. 2024-11-22 19:02:07 +01:00
Kai DeLorenzo
c85f71b601
update full screen strings 2024-11-21 11:47:45 -05:00
Koen J
196e55899e Fixed DASH generation with subtitles. 2024-11-21 17:25:18 +01:00
Koen J
ebec45076d Merge branch 'landscape' of gitlab.futo.org:videostreaming/grayjay into landscape 2024-11-21 10:47:12 +01:00
Koen J
561d9ae987 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into landscape 2024-11-21 10:46:39 +01:00
Koen
8950bd94cb Merge branch 'ignore-offline-error' into 'master'
hide unable to resolve host exceptions when there are more videos in a playlist

See merge request videostreaming/grayjay!24
2024-11-21 09:43:52 +00:00
Koen
f416f197bc Merge branch 'subscription-submission-modal' into 'master'
Subscription submission modal

See merge request videostreaming/grayjay!38
2024-11-21 09:43:25 +00:00
Koen
65afe5a0e6 Merge branch 'm3u8-parse-fix' into 'master'
fixed m3u8 parsing bug that caused Patreon video downloads to crash Grayjay

See merge request videostreaming/grayjay!39
2024-11-21 09:43:14 +00:00
Koen
4b5d347413 Merge branch 'remove-search-limit' into 'master'
allow searches down to 1 character

See merge request videostreaming/grayjay!40
2024-11-21 09:43:02 +00:00
Kai DeLorenzo
4dcc2dd0ca
revert style changes 2024-11-19 11:45:54 -05:00
Kai DeLorenzo
2a7a332160 Merge branch 'master' into 'subscription-submission-modal'
# Conflicts:
#   app/src/main/res/values/strings.xml
2024-11-19 16:29:32 +00:00
Kelvin
27ee1eabda Merge branch 'capabilities-fix' into 'master'
fixed incorrect capabilities call

See merge request videostreaming/grayjay!37
2024-11-19 16:03:15 +00:00
Koen
0034665965 Merge branch 'accessability' into 'master'
fix: Remove extra strings and remove single quote

See merge request videostreaming/grayjay!46
2024-11-18 17:28:16 +00:00
Kai DeLorenzo
a69692be18 Merge branch 'master' into 'subscription-submission-modal'
# Conflicts:
#   app/src/main/res/values/strings.xml
2024-11-18 15:05:10 +00:00
Kai DeLorenzo
dc76152166 Merge branch 'master' into 'landscape'
# Conflicts:
#   app/src/main/res/layout/activity_polycentric_create_profile.xml
2024-11-18 15:03:06 +00:00
zvonimir
d7f3ae696c fix: Remove extra strings and remove single quote 2024-11-18 15:28:48 +01:00
Koen
71f5449d34 Merge branch 'accessability' into 'master'
feat: add android:contentDescription. Closes #1181

See merge request videostreaming/grayjay!45
2024-11-18 13:38:08 +00:00
Koen J
0e64fa8d4c Added a try catch to HandlePacket. 2024-11-18 14:28:56 +01:00
Koen J
73b048d4c5 Fixed resume not always working. 2024-11-18 14:20:31 +01:00
zvonimir
1c05b39861 feat: add android:contentDescription. Closes #1181 2024-11-18 12:54:32 +01:00
Koen J
7cfa6c163f Use SINGLE_TOP instead of CLEAR_TOP and do not start a new task for import data. 2024-11-18 12:48:02 +01:00
Koen J
2d4af2e867 Updated submodules. 2024-11-18 12:35:40 +01:00
Koen
1eeaffc442 Merge branch 'sync-add-subopcode' into 'master'
Added subopcode byte.

See merge request videostreaming/grayjay!43
2024-11-18 11:30:03 +00:00
Koen
82125b33ed Merge branch 'zvonimir-dev' into 'master'
docs: Update CONTRIBUTION.md to reflect changes of accepting

See merge request videostreaming/grayjay!42
2024-11-18 11:23:25 +00:00
Zvonimir Zrakić
42cbbc28fd docs: Update CONTRIBUTION.md to reflect changes of accepting 2024-11-18 11:23:25 +00:00
Koen J
a7cbb0e93c Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into landscape 2024-11-18 12:16:05 +01:00
Koen J
fde6148ece Added subopcode byte. 2024-11-18 11:27:55 +01:00
Koen
df1661d75a Merge branch 'zvonimir-dev' into 'master'
Update images in README and add building step for updating & fix services starting

See merge request videostreaming/grayjay!41
2024-11-15 13:42:51 +00:00
zvonimir
f938f79a35 fix: Services should not crash if already started 2024-11-15 14:39:15 +01:00
zvonimir
333f00235b docs: Fix source image path 2024-11-15 00:06:20 +01:00
zvonimir
c06475bfb3 docs: Update images in README and add building step for updating
submodules
2024-11-15 00:03:36 +01:00
Kelvin
d1a54d0cf3 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-11-14 23:15:23 +01:00
Kelvin
349437c06b Refs, History/subgroup import/export, broadcast sub change to synced devices 2024-11-14 23:15:15 +01:00
Kai DeLorenzo
1b03c83c84
allow searches down to 1 character 2024-11-14 09:18:49 -06:00
Koen J
bb749aacf1 Merge branch 'playstore-change' of gitlab.futo.org:videostreaming/grayjay 2024-11-14 10:31:51 +01:00
Kelvin
3a41b89e52 Requests 2024-11-13 18:39:01 +01:00
Kelvin
70cbc77381 Remove items from watchlater if 70% watched 2024-11-12 22:02:08 +01:00
Kelvin
3a99f5dfaa Dup declaration activity 2024-11-12 20:33:56 +01:00
Kelvin
f24435ecf4 Confirmation on delete group on group tab 2024-11-12 15:58:20 +01:00
Kelvin
4a708e316a Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-11-12 15:03:08 +01:00
Kelvin
c2b47c998d Fallback in case isUpdating not cleared 2024-11-12 15:02:59 +01:00
Koen J
534f7b3134 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-11-12 14:40:17 +01:00
Koen J
d5d2692317 - Fixed crash when ServiceDiscoverer is already started.
- Added music.youtube.com to handled URLs in non-playstore version
- Added open.spotify.com to handled URLs in non-playstore version
- Reverse portrait toggle when on now allows reverse portrait and when off will use last orientation rather than portrait.
- Added playlist video deletion confirmation dialog (with a setting to disable)
- Added a dialog showing the uploaded log id with a copy button
2024-11-12 14:40:04 +01:00
Kelvin
dc9cc7b00f WIP updating block for subscriptions 2024-11-12 14:37:57 +01:00
Kelvin
965e74c7e2 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-11-12 13:38:16 +01:00
Kelvin
096ba54eb1 Autobackup password check, Filesize estimation int overflow fix, Deleting subscriptions part of subgroup fix, License delete confirm dialog, double check pager closed runtimes 2024-11-12 13:37:54 +01:00
Koen J
f4e38f9e50 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-11-12 11:51:16 +01:00
Koen J
c0d9409176 Added e-mail confirmation for payment and fixed number formats. 2024-11-12 11:51:07 +01:00
zvonimir
7d1f565749 docs: test with lowere max-width 2024-11-11 21:06:02 +01:00
zvonimir
dfec4ada3b docs: test new format for images 2024-11-11 21:02:47 +01:00
zvonimir
cd695cf265 docs: Update README screenshots 2024-11-11 20:35:06 +01:00
Kelvin
47ff2e0c38 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-11-07 16:37:14 +01:00
Kelvin
db7c09291f Subscription group sync, playlist sync, send to device support mobile to desktop 2024-11-07 16:36:49 +01:00
Kai
01f10c49ba
remove android:exported attribute from unstable xml 2024-11-06 10:08:08 -06:00
Kai
1ff0692a72
force portrait for most activities 2024-11-06 09:48:19 -06:00
Kai
116e6099d5
prevent landscape for QR code scanner
add scrolling to polycentric creation view
2024-11-05 14:58:39 -06:00
Kai
18ccaadc5b
stripped down data to only URL and plugin id. removed modal data preview 2024-11-05 13:55:45 -06:00
Kai
8f6eac7ca2
undo some formatting changes 2024-11-05 13:27:22 -06:00
Kai
f4610d0df5
fixed m3u8 parsing bug that caused Patreon video downloads to crash Grayjay 2024-11-04 10:40:48 -06:00
Kai
bf1a6b7d0a
updated URL and catch HTTP errors 2024-10-30 15:07:29 -05:00
Kai
b3fd05e62e
fixed incorrect capabilities call 2024-10-28 12:44:23 -05:00
Kai
f7ce365618
fix incorrect number of columns in creator search 2024-10-23 15:57:44 -05:00
Kai
77a558dbe5
add subscription submission dialog 2024-10-23 15:43:56 -05:00
Kai
cc0c400b28
improved vertical video detection and auto full screen for vertical videos 2024-10-22 13:01:02 -05:00
Koen
2bcd59cbfa Update LICENSE.md 2024-10-21 23:20:54 +00:00
Koen
5139acc7f1 Update LICENSE.md 2024-10-21 23:19:15 +00:00
Kai
1564433e02
added detection of vertical video and improved full screen handling 2024-10-21 13:16:20 -05:00
Kai
1339beb7cd
change fullscreen to full screen to align with the more common spelling 2024-10-21 09:59:10 -05:00
Kai
cd9698ea48
manifest fixes and sensor update 2024-10-21 09:52:45 -05:00
Kai
c8f8e4c5eb
Merge branch 'refs/heads/master' into landscape 2024-10-21 09:43:03 -05:00
Koen J
0b4ab46563 Added aws s3 cp for releasing artifacts. 2024-10-20 07:46:41 +02:00
Koen J
ea1ac86134 Updated submodules and implemented timer to make sync connect requests less frequent upon failure. 2024-10-19 07:37:12 +02:00
Kelvin
790331e798 History sync support 2024-10-01 14:30:33 +02:00
Kelvin
f5d9b2ba41 Harbor link, additional description 2024-09-30 20:20:16 +02:00
Kelvin
7f26ac00b1 Polycentric fixes, missing sync files 2024-09-30 18:28:43 +02:00
Kelvin
fcbab10434 Sync implementation for subscriptions, tracking subscription removals, add fcast link to cast tutorial description. 2024-09-30 16:39:10 +02:00
Kelvin
c4061cc6ac Minor refactor, handle simple sendToDevice packet 2024-09-26 23:26:52 +02:00
Kelvin
12ac4d6b6f :Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-09-26 19:17:56 +02:00
Kelvin
3d06e62cd4 Minor dev fix 2024-09-26 19:17:51 +02:00
Koen
d7d23e1048 Merge branch 'sync-ui' into 'master'
Sync protocol

See merge request videostreaming/grayjay!35
2024-09-26 12:40:47 +00:00
Koen
1fe9b70176 Sync protocol 2024-09-26 12:40:47 +00:00
Kai
a9cf8dd71a
more sizing fixes 2024-09-12 10:34:46 -05:00
Kai
3299261db3
fixed video sizing 2024-09-12 10:28:06 -05:00
Kai
e465ec8278
remove auto fullscreen for tablets 2024-09-12 08:47:00 -05:00
Kai
d0e4a0aa1f
update settings 2024-09-11 21:45:18 -05:00
Kai
74efec3235
Merge branch 'refs/heads/master' into landscape
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/SimpleOrientationListener.kt
#	app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt
2024-09-11 21:37:34 -05:00
Kai
13516087f2
minimized player fixes 2024-09-11 20:24:16 -05:00
Kelvin
0a0c16524a Allow hiding privacy mode and FAQ without breaking existing orderings 2024-09-10 23:10:36 +02:00
Kelvin
9b843a155e Revert "Allow more tabs to be hidden."
This reverts commit 8c4e511883
2024-09-10 20:51:30 +00:00
Kai
68eb0cc8f2
added comment 2024-09-10 09:40:24 -05:00
Kai
cb9cecfa5d
rotation fixes 2024-09-10 09:36:59 -05:00
Kai DeLorenzo
da6eef905c
hide unable to resolve host exceptions when there are more videos in a playlist 2024-07-05 11:48:35 -05:00
442 changed files with 24325 additions and 3316 deletions

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:

View file

@ -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)

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.

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
.gitmodules vendored
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

View file

@ -49,9 +49,23 @@ We encourage developers to write their own plugins. Please refer to the "Getting
## Contributing to Core
**We are currently not accepting contributions to the core.**
The core is currently licensed under the FUTO Temporary License (FTL). The licensing and ownership of contributions to the core are complex topics that we are still working on. We'll update these guidelines when we have more clarity.
### License
The core is currently licensed under the [Source First License 1.1](./LICENSE.md). All contributors have to sign FUTO Individual Contributor License Agreement before contributions can be accepted. You can read more about it at [https://cla.futo.org/](https://cla.futo.org/).
### How to Contribute
1. Fork the core repository.
2. Clone your fork.
3. Make your changes.
4. Commit and push your changes.
5. Open a pull request.
### Guidelines
- Ensure your code adheres to the existing style.
- Include documentation and unit tests (where applicable).
---

View file

@ -1,4 +1,4 @@
# Grayjay Core License 1.0
# Source First License 1.1
## Acceptance
By using the software, you agree to all of the terms and conditions below.
@ -16,7 +16,7 @@ Notwithstanding the above, you may not remove or obscure any functionality in th
You may not alter, remove, or obscure any licensing, copyright, or other notices of the Licensor in the software. Any use of the Licensors trademarks is subject to applicable law.
## Patents
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
If you make any written claim that the software infringes or contributes to infringement of any patent, your license for the software granted under these terms ends immediately. If your company makes such a claim, your license ends immediately for work on behalf of your company.
## Notices
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms. If you modify the software, you must include in any modified copies of the software a prominent notice stating that you have modified the software, such as but not limited to, a statement in a readme file or an in-application about section.

View file

@ -9,8 +9,8 @@ technologies that frustrate centralization and industry consolidation.
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/video.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/video-details.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/video.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/video-details.png" height="700" /></b></td>
</tr>
<tr>
<td>Video</td>
@ -24,12 +24,10 @@ The FUTO media app is a player that exposes multiple video websites as sources i
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/sources.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/sources-disabled.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/source.png" height="700" /></b></td>
</tr>
<tr>
<td>Sources (all enabled)</td>
<td>Sources (one disabled)</td>
<td>Sources</td>
</tr>
</table>
@ -38,7 +36,7 @@ Additional sources can also be installed. These sources are JavaScript sources,
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/source-install.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/source-settings.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/source-settings.png" height="700" /></b></td>
</tr>
<tr>
<td>Install a new source</td>
@ -54,8 +52,8 @@ When a user enters a search term into the search bar, the query is posted to th
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/search-list.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/search-preview.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/search-list.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/search-preview.png" height="700" /></b></td>
</tr>
<tr>
<td>Search (list)</td>
@ -71,7 +69,7 @@ Creators are able to configure their profile using NeoPass.
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/channel.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/channel.png" height="700" /></b></td>
</tr>
<tr>
<td>Channel</td>
@ -112,7 +110,7 @@ The app offers a lot of settings customizing how the app looks and feels. An exa
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/settings.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/settings.png" height="700" /></b></td>
</tr>
<tr>
<td>Settings</td>
@ -125,8 +123,8 @@ Playlists allow you to make a collection of videos that you can create and custo
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/playlists.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/playlist.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/playlists.png" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/playlist.png" height="700" /></b></td>
</tr>
<tr>
<td>Playlists</td>
@ -142,7 +140,7 @@ Both individual videos and playlists can be downloaded for local, offline playba
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/downloads.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/downloads.png" height="700" /></b></td>
</tr>
<tr>
<td>Downloads</td>
@ -157,7 +155,7 @@ For more information about casting please click [here](./docs/casting.md).
<table border="0">
<tr>
<td><b style="font-size:30px"><img src="images/casting.jpg" height="700" /></b></td>
<td><b style="font-size:30px"><img src="images/casting.png" height="700" /></b></td>
</tr>
<tr>
<td>Casting</td>
@ -182,6 +180,12 @@ In the future we hope to offer users the choice of their desired recommendation
1. Download a copy of the repository.
2. Open the project in Android Studio: Once the repository is cloned, you can open it in Android Studio by selecting "Open an Existing Project" from the welcome screen and navigating to the directory where you cloned the repository.
3. Open the terminal in Android Studio by clicking on the terminal icon on bottom left and run the following command:
```sh
git submodule update --init --recursive
```
3. Build the project: With the project open in Android Studio, you can build it by selecting "Build > Make Project" from the main menu. This will compile the code and generate an APK file that you can install on your device or emulator.
4. Run the project: To run the project, select "Run > Run 'app'" from the main menu. This will launch the app on your device or emulator, allowing you to test it and make any necessary changes.
@ -199,7 +203,6 @@ Create a tag on the master branch, incrementing the last version number by 1 (fo
Click on the CI/CD tab, you should now see the tests and build are in progress. If the build succeeds the last step will become available. The last step is a manual action which can be triggered by clicking the run button on the action. This action will deploy the build to all users using the app through auto-update.
## Documentation
The documentation can be found [here](https://gitlab.futo.org/videostreaming/documents/-/wikis/API-Overview).

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'

View file

@ -0,0 +1,266 @@
package com.futo.platformplayer
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.net.Socket
import java.nio.ByteBuffer
import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds
class SyncServerTests {
//private val relayHost = "relay.grayjay.app"
//private val relayKey = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw="
private val relayKey = "XlUaSpIlRaCg0TGzZ7JYmPupgUHDqTZXUUBco2K7ejw="
private val relayHost = "192.168.1.175"
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: ((SyncSocketSession, String, String?) -> Boolean)? = 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()
socketSession.startAsInitiator(relayKey)
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()
}
}
class AlwaysAuthorized : IAuthorizable {
override val isAuthorized: Boolean get() = true
}

View file

@ -36,6 +36,12 @@
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
<receiver android:name=".receivers.MediaButtonReceiver" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<service android:name=".services.MediaPlaybackService"
android:enabled="true"
android:foregroundServiceType="mediaPlayback" />
@ -51,9 +57,8 @@
android:name=".activities.MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout"
android:exported="true"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar"
android:launchMode="singleTask"
android:launchMode="singleInstance"
android:resizeableActivity="true"
android:supportsPictureInPicture="true">
@ -146,14 +151,11 @@
<data android:scheme="polycentric" />
</intent-filter>
</activity>
<activity
android:name=".activities.TestActivity"
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"
@ -173,7 +175,6 @@
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.AddSourceActivity"
android:screenOrientation="sensorPortrait"
android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar">
<intent-filter>
@ -217,7 +218,6 @@
android:name=".activities.ManageTabsActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.QRCaptureActivity"
android:screenOrientation="sensorPortrait"
@ -226,5 +226,17 @@
android:name=".activities.FCastGuideActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.SyncHomeActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.SyncPairActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.SyncShowPairingCodeActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
</application>
</manifest>

File diff suppressed because one or more lines are too long

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
}
}
}
@ -367,6 +374,16 @@ class VideoUrlSource {
this.requestModifier = obj.requestModifier;
}
}
class VideoUrlWidevineSource extends VideoUrlSource {
constructor(obj) {
super(obj);
this.plugin_type = "VideoUrlWidevineSource";
this.licenseUri = obj.licenseUri;
if(obj.getLicenseRequestExecutor)
this.getLicenseRequestExecutor = obj.getLicenseRequestExecutor;
}
}
class VideoUrlRangeSource extends VideoUrlSource {
constructor(obj) {
super(obj);
@ -399,8 +416,26 @@ class AudioUrlWidevineSource extends AudioUrlSource {
super(obj);
this.plugin_type = "AudioUrlWidevineSource";
this.bearerToken = obj.bearerToken;
this.licenseUri = obj.licenseUri;
if(obj.getLicenseRequestExecutor)
this.getLicenseRequestExecutor = obj.getLicenseRequestExecutor;
// deprecated api conversion
if(obj.bearerToken) {
this.getLicenseRequestExecutor = () => {
return {
executeRequest: (url, _headers, _method, license_request_data) => {
return http.POST(
url,
license_request_data,
{ Authorization: `Bearer ${obj.bearerToken}` },
false,
true
).body
}
}
}
}
}
}
class AudioUrlRangeSource extends AudioUrlSource {
@ -443,6 +478,16 @@ class DashSource {
this.requestModifier = obj.requestModifier;
}
}
class DashWidevineSource extends DashSource {
constructor(obj) {
super(obj);
this.plugin_type = "DashWidevineSource";
this.licenseUri = obj.licenseUri;
if(obj.getLicenseRequestExecutor)
this.getLicenseRequestExecutor = obj.getLicenseRequestExecutor;
}
}
class DashManifestRawSource {
constructor(obj) {
obj = obj ?? {};

View file

@ -1,122 +0,0 @@
import android.app.Activity
import android.content.Context
import android.content.pm.ActivityInfo
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class AdvancedOrientationListener(private val activity: Activity, private val lifecycleScope: CoroutineScope) {
private val sensorManager: SensorManager = activity.getSystemService(Context.SENSOR_SERVICE) as SensorManager
private val accelerometer: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
private val magnetometer: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
private var lastOrientation: Int = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
private var lastStableOrientation: Int = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
private var lastOrientationChangeTime = 0L
private val debounceTime = 200L
private val stabilityThresholdTime = 800L
private var deviceAspectRatio: Float = 1.0f
private val gravity = FloatArray(3)
private val geomagnetic = FloatArray(3)
private val rotationMatrix = FloatArray(9)
private val orientationAngles = FloatArray(3)
val onOrientationChanged = Event1<Int>()
private val sensorListener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
when (event.sensor.type) {
Sensor.TYPE_ACCELEROMETER -> {
System.arraycopy(event.values, 0, gravity, 0, gravity.size)
}
Sensor.TYPE_MAGNETIC_FIELD -> {
System.arraycopy(event.values, 0, geomagnetic, 0, geomagnetic.size)
}
}
if (gravity.isNotEmpty() && geomagnetic.isNotEmpty()) {
val success = SensorManager.getRotationMatrix(rotationMatrix, null, gravity, geomagnetic)
if (success) {
SensorManager.getOrientation(rotationMatrix, orientationAngles)
val azimuth = Math.toDegrees(orientationAngles[0].toDouble()).toFloat()
val pitch = Math.toDegrees(orientationAngles[1].toDouble()).toFloat()
val roll = Math.toDegrees(orientationAngles[2].toDouble()).toFloat()
val newOrientation = when {
roll in -155f .. -15f && isWithinThreshold(pitch, 0f, 30.0) -> {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
roll in 15f .. 155f && isWithinThreshold(pitch, 0f, 30.0) -> {
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
}
isWithinThreshold(pitch, -90f, 30.0 * deviceAspectRatio) && roll in -15f .. 15f -> {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
isWithinThreshold(pitch, 90f, 30.0 * deviceAspectRatio) && roll in -15f .. 15f -> {
ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
}
else -> lastOrientation
}
//Logger.i("AdvancedOrientationListener", "newOrientation = ${newOrientation}, roll = ${roll}, pitch = ${pitch}, azimuth = ${azimuth}")
if (newOrientation != lastStableOrientation) {
val currentTime = System.currentTimeMillis()
if (currentTime - lastOrientationChangeTime > debounceTime) {
lastOrientationChangeTime = currentTime
lastStableOrientation = newOrientation
lifecycleScope.launch(Dispatchers.Main) {
try {
delay(stabilityThresholdTime)
if (newOrientation == lastStableOrientation) {
lastOrientation = newOrientation
onOrientationChanged.emit(newOrientation)
}
} catch (e: Throwable) {
Logger.i(TAG, "Failed to trigger onOrientationChanged", e)
}
}
}
}
}
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
}
private fun isWithinThreshold(value: Float, target: Float, threshold: Double): Boolean {
return Math.abs(value - target) <= threshold
}
init {
sensorManager.registerListener(sensorListener, accelerometer, SensorManager.SENSOR_DELAY_GAME)
sensorManager.registerListener(sensorListener, magnetometer, SensorManager.SENSOR_DELAY_GAME)
val metrics = activity.resources.displayMetrics
deviceAspectRatio = (metrics.heightPixels.toFloat() / metrics.widthPixels.toFloat())
if (deviceAspectRatio == 0.0f)
deviceAspectRatio = 1.0f
lastOrientation = activity.resources.configuration.orientation
}
fun stopListening() {
sensorManager.unregisterListener(sensorListener)
}
companion object {
private val TAG = "AdvancedOrientationListener"
}
}

View file

@ -226,6 +226,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 {

View file

@ -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;
}

View file

@ -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)
}
}

View file

@ -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")

View file

@ -0,0 +1,192 @@
package com.futo.platformplayer
import com.google.common.base.Preconditions
import com.google.common.io.ByteStreams
import com.google.common.primitives.Ints
import com.google.common.primitives.Longs
import java.io.DataInput
import java.io.DataInputStream
import java.io.EOFException
import java.io.FilterInputStream
import java.io.IOException
import java.io.InputStream
class LittleEndianDataInputStream
/**
* Creates a `LittleEndianDataInputStream` that wraps the given stream.
*
* @param in the stream to delegate to
*/
(`in`: InputStream?) : FilterInputStream(Preconditions.checkNotNull(`in`)), DataInput {
/** This method will throw an [UnsupportedOperationException]. */
override fun readLine(): String {
throw UnsupportedOperationException("readLine is not supported")
}
@Throws(IOException::class)
override fun readFully(b: ByteArray) {
ByteStreams.readFully(this, b)
}
@Throws(IOException::class)
override fun readFully(b: ByteArray, off: Int, len: Int) {
ByteStreams.readFully(this, b, off, len)
}
@Throws(IOException::class)
override fun skipBytes(n: Int): Int {
return `in`.skip(n.toLong()).toInt()
}
@Throws(IOException::class)
override fun readUnsignedByte(): Int {
val b1 = `in`.read()
if (0 > b1) {
throw EOFException()
}
return b1
}
/**
* Reads an unsigned `short` as specified by [DataInputStream.readUnsignedShort],
* except using little-endian byte order.
*
* @return the next two bytes of the input stream, interpreted as an unsigned 16-bit integer in
* little-endian byte order
* @throws IOException if an I/O error occurs
*/
@Throws(IOException::class)
override fun readUnsignedShort(): Int {
val b1 = readAndCheckByte()
val b2 = readAndCheckByte()
return Ints.fromBytes(0.toByte(), 0.toByte(), b2, b1)
}
/**
* Reads an integer as specified by [DataInputStream.readInt], except using little-endian
* byte order.
*
* @return the next four bytes of the input stream, interpreted as an `int` in little-endian
* byte order
* @throws IOException if an I/O error occurs
*/
@Throws(IOException::class)
override fun readInt(): Int {
val b1 = readAndCheckByte()
val b2 = readAndCheckByte()
val b3 = readAndCheckByte()
val b4 = readAndCheckByte()
return Ints.fromBytes(b4, b3, b2, b1)
}
/**
* Reads a `long` as specified by [DataInputStream.readLong], except using
* little-endian byte order.
*
* @return the next eight bytes of the input stream, interpreted as a `long` in
* little-endian byte order
* @throws IOException if an I/O error occurs
*/
@Throws(IOException::class)
override fun readLong(): Long {
val b1 = readAndCheckByte()
val b2 = readAndCheckByte()
val b3 = readAndCheckByte()
val b4 = readAndCheckByte()
val b5 = readAndCheckByte()
val b6 = readAndCheckByte()
val b7 = readAndCheckByte()
val b8 = readAndCheckByte()
return Longs.fromBytes(b8, b7, b6, b5, b4, b3, b2, b1)
}
/**
* Reads a `float` as specified by [DataInputStream.readFloat], except using
* little-endian byte order.
*
* @return the next four bytes of the input stream, interpreted as a `float` in
* little-endian byte order
* @throws IOException if an I/O error occurs
*/
@Throws(IOException::class)
override fun readFloat(): Float {
return java.lang.Float.intBitsToFloat(readInt())
}
/**
* Reads a `double` as specified by [DataInputStream.readDouble], except using
* little-endian byte order.
*
* @return the next eight bytes of the input stream, interpreted as a `double` in
* little-endian byte order
* @throws IOException if an I/O error occurs
*/
@Throws(IOException::class)
override fun readDouble(): Double {
return java.lang.Double.longBitsToDouble(readLong())
}
@Throws(IOException::class)
override fun readUTF(): String {
return DataInputStream(`in`).readUTF()
}
/**
* Reads a `short` as specified by [DataInputStream.readShort], except using
* little-endian byte order.
*
* @return the next two bytes of the input stream, interpreted as a `short` in little-endian
* byte order.
* @throws IOException if an I/O error occurs.
*/
@Throws(IOException::class)
override fun readShort(): Short {
return readUnsignedShort().toShort()
}
/**
* Reads a char as specified by [DataInputStream.readChar], except using little-endian
* byte order.
*
* @return the next two bytes of the input stream, interpreted as a `char` in little-endian
* byte order
* @throws IOException if an I/O error occurs
*/
@Throws(IOException::class)
override fun readChar(): Char {
return readUnsignedShort().toChar()
}
@Throws(IOException::class)
override fun readByte(): Byte {
return readUnsignedByte().toByte()
}
@Throws(IOException::class)
override fun readBoolean(): Boolean {
return readUnsignedByte() != 0
}
/**
* Reads a byte from the input stream checking that the end of file (EOF) has not been
* encountered.
*
* @return byte read from input
* @throws IOException if an error is encountered while reading
* @throws EOFException if the end of file (EOF) is encountered.
*/
@Throws(IOException::class, EOFException::class)
private fun readAndCheckByte(): Byte {
val b1 = `in`.read()
if (-1 == b1) {
throw EOFException()
}
return b1.toByte()
}
}

View file

@ -0,0 +1,144 @@
package com.futo.platformplayer
import com.google.common.base.Preconditions
import com.google.common.primitives.Longs
import java.io.*
class LittleEndianDataOutputStream
/**
* Creates a `LittleEndianDataOutputStream` that wraps the given stream.
*
* @param out the stream to delegate to
*/
(out: OutputStream?) : FilterOutputStream(DataOutputStream(Preconditions.checkNotNull(out))),
DataOutput {
@Throws(IOException::class)
override fun write(b: ByteArray, off: Int, len: Int) {
// Override slow FilterOutputStream impl
out.write(b, off, len)
}
@Throws(IOException::class)
override fun writeBoolean(v: Boolean) {
(out as DataOutputStream).writeBoolean(v)
}
@Throws(IOException::class)
override fun writeByte(v: Int) {
(out as DataOutputStream).writeByte(v)
}
@Deprecated(
"""The semantics of {@code writeBytes(String s)} are considered dangerous. Please use
{@link #writeUTF(String s)}, {@link #writeChars(String s)} or another write method instead."""
)
@Throws(
IOException::class
)
override fun writeBytes(s: String) {
(out as DataOutputStream).writeBytes(s)
}
/**
* Writes a char as specified by [DataOutputStream.writeChar], except using
* little-endian byte order.
*
* @throws IOException if an I/O error occurs
*/
@Throws(IOException::class)
override fun writeChar(v: Int) {
writeShort(v)
}
/**
* Writes a `String` as specified by [DataOutputStream.writeChars], except
* each character is written using little-endian byte order.
*
* @throws IOException if an I/O error occurs
*/
@Throws(IOException::class)
override fun writeChars(s: String) {
for (i in 0 until s.length) {
writeChar(s[i].code)
}
}
/**
* Writes a `double` as specified by [DataOutputStream.writeDouble], except
* using little-endian byte order.
*
* @throws IOException if an I/O error occurs
*/
@Throws(IOException::class)
override fun writeDouble(v: Double) {
writeLong(java.lang.Double.doubleToLongBits(v))
}
/**
* Writes a `float` as specified by [DataOutputStream.writeFloat], except using
* little-endian byte order.
*
* @throws IOException if an I/O error occurs
*/
@Throws(IOException::class)
override fun writeFloat(v: Float) {
writeInt(java.lang.Float.floatToIntBits(v))
}
/**
* Writes an `int` as specified by [DataOutputStream.writeInt], except using
* little-endian byte order.
*
* @throws IOException if an I/O error occurs
*/
@Throws(IOException::class)
override fun writeInt(v: Int) {
val bytes = byteArrayOf(
(0xFF and v).toByte(),
(0xFF and (v shr 8)).toByte(),
(0xFF and (v shr 16)).toByte(),
(0xFF and (v shr 24)).toByte()
)
out.write(bytes)
}
/**
* Writes a `long` as specified by [DataOutputStream.writeLong], except using
* little-endian byte order.
*
* @throws IOException if an I/O error occurs
*/
@Throws(IOException::class)
override fun writeLong(v: Long) {
val bytes = Longs.toByteArray(java.lang.Long.reverseBytes(v))
write(bytes, 0, bytes.size)
}
/**
* Writes a `short` as specified by [DataOutputStream.writeShort], except using
* little-endian byte order.
*
* @throws IOException if an I/O error occurs
*/
@Throws(IOException::class)
override fun writeShort(v: Int) {
val bytes = byteArrayOf(
(0xFF and v).toByte(),
(0xFF and (v shr 8)).toByte()
)
out.write(bytes)
}
@Throws(IOException::class)
override fun writeUTF(str: String) {
(out as DataOutputStream).writeUTF(str)
}
// Overriding close() because FilterOutputStream's close() method pre-JDK8 has bad behavior:
// it silently ignores any exception thrown by flush(). Instead, just close the delegate stream.
// It should flush itself if necessary.
@Throws(IOException::class)
override fun close() {
out.close()
}
}

View file

@ -2,11 +2,8 @@ package com.futo.platformplayer
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Context.POWER_SERVICE
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.webkit.CookieManager
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.MainActivity
@ -14,6 +11,7 @@ import com.futo.platformplayer.activities.ManageTabsActivity
import com.futo.platformplayer.activities.PolycentricHomeActivity
import com.futo.platformplayer.activities.PolycentricProfileActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.activities.SyncHomeActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
@ -26,7 +24,6 @@ import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePayment
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateUpdate
import com.futo.platformplayer.stores.FragmentedStorage
@ -36,9 +33,7 @@ import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormField
import com.futo.platformplayer.views.fields.FormFieldButton
import com.futo.platformplayer.views.fields.FormFieldWarning
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.stripe.android.customersheet.injection.CustomerSheetViewModelModule_Companion_ContextFactory.context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -63,6 +58,15 @@ class Settings : FragmentedStorageFileJson() {
@Transient
val onTabsChanged = Event0();
@FormField(R.string.sync_grayjay, FieldForm.BUTTON, R.string.sync_grayjay_description, -8)
@FormFieldButton(R.drawable.ic_update)
fun syncGrayjay() {
SettingsActivity.getActivity()?.let {
it.startActivity(Intent(it, SyncHomeActivity::class.java))
}
}
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -7)
@FormFieldButton(R.drawable.ic_person)
fun managePolycentricIdentity() {
@ -140,7 +144,6 @@ class Settings : FragmentedStorageFileJson() {
fun import() {
val act = SettingsActivity.getActivity() ?: return;
val intent = MainActivity.getImportOptionsIntent(act);
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK;
act.startActivity(intent);
}
@ -202,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;
@ -213,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;
@ -251,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)
@ -288,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;
@ -350,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;
@ -374,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];
@ -409,17 +425,13 @@ class Settings : FragmentedStorageFileJson() {
var preferredPreviewQuality: Int = 5;
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
@FormField(R.string.simplify_sources, FieldForm.TOGGLE, R.string.simplify_sources_description, 4)
var simplifySources: Boolean = true;
@FormField(R.string.auto_rotate, FieldForm.DROPDOWN, -1, 5)
@DropdownFieldOptionsId(R.array.system_enabled_disabled_array)
var autoRotate: Int = 2;
@FormField(R.string.always_allow_reverse_landscape_auto_rotate, FieldForm.TOGGLE, R.string.always_allow_reverse_landscape_auto_rotate_description, 5)
var alwaysAllowReverseLandscapeAutoRotate: Boolean = true
fun isAutoRotate() = (autoRotate == 1 && !StatePlayer.instance.rotationLock) || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate() && !StatePlayer.instance.rotationLock);
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 7)
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 6)
@DropdownFieldOptionsId(R.array.player_background_behavior)
var backgroundPlay: Int = 2;
@ -474,17 +486,6 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.reverse_portrait, FieldForm.TOGGLE, R.string.reverse_portrait_description, 14)
var reversePortrait: Boolean = false;
@FormField(R.string.rotation_zone, FieldForm.DROPDOWN, R.string.rotation_zone_description, 15)
@DropdownFieldOptionsId(R.array.rotation_zone)
var rotationZone: Int = 2;
@FormField(R.string.stability_threshold_time, FieldForm.DROPDOWN, R.string.stability_threshold_time_description, 16)
@DropdownFieldOptionsId(R.array.rotation_threshold_time)
var stabilityThresholdTime: Int = 1;
@FormField(R.string.full_autorotate_lock, FieldForm.TOGGLE, R.string.full_autorotate_lock_description, 17)
var fullAutorotateLock: Boolean = false;
@FormField(R.string.prefer_webm, FieldForm.TOGGLE, R.string.prefer_webm_description, 18)
var preferWebmVideo: Boolean = false;
@FormField(R.string.prefer_webm_audio, FieldForm.TOGGLE, R.string.prefer_webm_audio_description, 19)
@ -495,6 +496,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.autoplay, FieldForm.TOGGLE, R.string.autoplay, 21)
var autoplay: Boolean = false;
@FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22)
var deleteFromWatchLaterAuto: Boolean = true;
}
@FormField(R.string.comments, "group", R.string.comments_description, 6)
@ -579,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)
@ -650,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;
@ -852,10 +864,14 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 2)
fun clearPayment() {
StatePayment.instance.clearLicenses();
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
it.reloadSettings();
SettingsActivity.getActivity()?.let { context ->
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete your license?", {
StatePayment.instance.clearLicenses();
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
it.reloadSettings();
}
})
}
}
}
@ -864,12 +880,16 @@ class Settings : FragmentedStorageFileJson() {
var other = Other();
@Serializable
class Other {
@FormField(R.string.bypass_rotation_prevention, FieldForm.TOGGLE, R.string.bypass_rotation_prevention_description, 1)
@FormFieldWarning(R.string.bypass_rotation_prevention_warning)
var bypassRotationPrevention: Boolean = false;
@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, 1)
@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, 5)
var polycentricLocalCache: Boolean = true;
}
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
@ -901,7 +921,33 @@ class Settings : FragmentedStorageFileJson() {
var pan: Boolean = true;
}
@FormField(R.string.info, FieldForm.GROUP, -1, 20)
@FormField(R.string.synchronization, FieldForm.GROUP, -1, 20)
var synchronization = Synchronization();
@Serializable
class Synchronization {
@FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enabled_description, 1)
var enabled: Boolean = true;
@FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1)
var broadcast: Boolean = false;
@FormField(R.string.connect_discovered, FieldForm.TOGGLE, R.string.connect_discovered_description, 2)
var connectDiscovered: Boolean = true;
@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)
var info = Info();
@Serializable
class Info {

View file

@ -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

View file

@ -1,86 +0,0 @@
package com.futo.platformplayer
import android.app.Activity
import android.content.pm.ActivityInfo
import android.hardware.SensorManager
import android.view.OrientationEventListener
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class SimpleOrientationListener(
private val activity: Activity,
private val lifecycleScope: CoroutineScope
) {
private var lastOrientation: Int = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
private var lastStableOrientation: Int = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
private var _currentJob: Job? = null
val onOrientationChanged = Event1<Int>()
private val orientationListener = object : OrientationEventListener(activity, SensorManager.SENSOR_DELAY_UI) {
override fun onOrientationChanged(orientation: Int) {
//val rotationZone = 45
val stabilityThresholdTime = when (Settings.instance.playback.stabilityThresholdTime) {
0 -> 100L
1 -> 500L
2 -> 750L
3 -> 1000L
4 -> 1500L
5 -> 2000L
else -> 500L
}
val rotationZone = when (Settings.instance.playback.rotationZone) {
0 -> 15
1 -> 30
2 -> 45
else -> 45
}
val newOrientation = when {
orientation in (90 - rotationZone)..(90 + rotationZone - 1) -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
orientation in (180 - rotationZone)..(180 + rotationZone - 1) -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
orientation in (270 - rotationZone)..(270 + rotationZone - 1) -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
orientation in (360 - rotationZone)..(360 + rotationZone - 1) || orientation in 0..(rotationZone - 1) -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else -> lastOrientation
}
if (newOrientation != lastStableOrientation) {
lastStableOrientation = newOrientation
_currentJob?.cancel()
_currentJob = lifecycleScope.launch(Dispatchers.Main) {
try {
delay(stabilityThresholdTime)
if (newOrientation == lastStableOrientation) {
lastOrientation = newOrientation
onOrientationChanged.emit(newOrientation)
}
} catch (e: Throwable) {
Logger.i(TAG, "Failed to trigger onOrientationChanged", e)
}
}
}
}
}
init {
orientationListener.enable()
lastOrientation = activity.resources.configuration.orientation
}
fun stopListening() {
_currentJob?.cancel()
_currentJob = null
orientationListener.disable()
}
companion object {
private val TAG = "SimpleOrientationListener"
}
}

View file

@ -5,7 +5,9 @@ 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
import android.util.TypedValue
import android.view.Gravity
@ -198,34 +200,39 @@ 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;
};
view.findViewById<TextView>(R.id.dialog_text_details).apply {
if(textDetails == null)
if (textDetails == null)
this.visibility = View.GONE;
else
else {
this.text = textDetails;
}
};
view.findViewById<TextView>(R.id.dialog_text_code).apply {
if(code == null)
this.visibility = View.GONE;
if (code == null) this.visibility = View.GONE;
else {
this.text = code;
this.movementMethod = ScrollingMovementMethod.getInstance();
this.visibility = View.VISIBLE;
this.textAlignment = View.TEXT_ALIGNMENT_VIEW_START
}
};
view.findViewById<LinearLayout>(R.id.dialog_buttons).apply {
@ -274,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) {
@ -348,6 +356,13 @@ class UIDialogs {
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
}
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null) {
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE)
showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
}
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
val dialog = AutoUpdateDialog(context);
registerDialogOpened(dialog);
@ -360,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();

View file

@ -25,6 +25,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.downloads.VideoLocal
@ -78,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>();
@ -334,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
))
@ -369,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 {
@ -416,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),
@ -429,7 +462,7 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download));
},
invokeParent = false
)) +
)) else listOf()) +
videoSources
.filter { it.isDownloadable() }
.map {
@ -879,6 +912,12 @@ class UISlideOverlays {
val items = arrayListOf<View>();
val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist();
val isLimited = video?.url != null && StatePlatform.instance.getContentClientOrNull(video!!.url)?.let {
if (it is JSClient)
return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD
else false;
} ?: false;
if (lastUpdated != null) {
items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist",
@ -888,7 +927,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();
}))
);
@ -899,17 +939,18 @@ class UISlideOverlays {
val watchLater = StatePlaylists.instance.getWatchLater();
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
(listOf(
SlideUpMenuItem(
container.context,
R.drawable.ic_download,
container.context.getString(R.string.download),
container.context.getString(R.string.download_the_video),
tag = "download",
call = {
showDownloadVideoOverlay(video, container, true);
},
invokeParent = false
),
if(!isLimited && !video.isLive)
SlideUpMenuItem(
container.context,
R.drawable.ic_download,
container.context.getString(R.string.download),
container.context.getString(R.string.download_the_video),
tag = "download",
call = {
showDownloadVideoOverlay(video, container, true);
},
invokeParent = false
) else null,
SlideUpMenuItem(
container.context,
R.drawable.ic_share,
@ -936,7 +977,7 @@ class UISlideOverlays {
StateMeta.instance.addHiddenCreator(video.author.url);
UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
}))
+ actions)
+ actions).filterNotNull()
));
items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto",
@ -951,7 +992,7 @@ class UISlideOverlays {
"${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "",
"${watchLater.size} " + container.context.getString(R.string.videos),
tag = "watch later",
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }),
SlideUpMenuItem(container.context,
R.drawable.ic_history,
container.context.getString(R.string.add_to_history),
@ -983,7 +1024,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();
}));
}
@ -1010,7 +1052,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();
}))
);
@ -1032,16 +1075,11 @@ class UISlideOverlays {
StatePlayer.TYPE_WATCHLATER,
"${watchLater.size} " + container.context.getString(R.string.videos),
tag = "watch later",
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
SlideUpMenuItem(
container.context,
R.drawable.ic_download,
container.context.getString(R.string.download),
container.context.getString(R.string.download_the_video),
tag = container.context.getString(R.string.download),
call = { showDownloadVideoOverlay(video, container, true); },
invokeParent = false
))
call = {
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true))
UIDialogs.appToast("Added to watch later", false);
}),
)
);
val playlistItems = arrayListOf<SlideUpMenuItem>();
@ -1067,7 +1105,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 +1148,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 +1156,7 @@ class UISlideOverlays {
.toList();
onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) });
}
});
},
invokeParent = false
))
@ -1125,29 +1164,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();

View file

@ -26,12 +26,18 @@ import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.PlatformVideoWithTime
import com.futo.platformplayer.others.PlatformLinkMovementMethod
import java.io.File
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.nio.ByteBuffer
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 {
@ -230,4 +236,92 @@ fun String.decodeUnicode(): String {
i++
}
return sb.toString()
}
fun <T> smartMerge(targetArr: List<T>, toMerge: List<T>) : List<T>{
val missingToMerge = toMerge.filter { !targetArr.contains(it) }.toList();
val newArrResult = targetArr.toMutableList();
for(missing in missingToMerge) {
val newIndex = findNewIndex(toMerge, newArrResult, missing);
newArrResult.add(newIndex, missing);
}
return newArrResult;
}
fun <T> findNewIndex(originalArr: List<T>, newArr: List<T>, item: T): Int{
var originalIndex = originalArr.indexOf(item);
var newIndex = -1;
for(i in originalIndex-1 downTo 0) {
val previousItem = originalArr[i];
val indexInNewArr = newArr.indexOfFirst { it == previousItem };
if(indexInNewArr >= 0) {
newIndex = indexInNewArr + 1;
break;
}
}
if(newIndex < 0) {
for(i in originalIndex+1 until originalArr.size) {
val previousItem = originalArr[i];
val indexInNewArr = newArr.indexOfFirst { it == previousItem };
if(indexInNewArr >= 0) {
newIndex = indexInNewArr - 1;
break;
}
}
}
if(newIndex < 0)
return originalArr.size;
else
return newIndex;
}
fun ByteBuffer.toUtf8String(): String {
val remainingBytes = ByteArray(remaining())
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()
}

View file

@ -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)
}
}
}

View file

@ -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;

View file

@ -1,13 +1,14 @@
package com.futo.platformplayer.activities
import android.annotation.SuppressLint
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.media.AudioManager
import android.net.Uri
import android.net.wifi.WifiManager
import android.os.Bundle
import android.os.StrictMode
import android.os.StrictMode.VmPolicy
@ -29,10 +30,12 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
@ -70,6 +73,8 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFrag
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.UrlVideoWithTime
import com.futo.platformplayer.receivers.MediaButtonReceiver
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
@ -80,6 +85,7 @@ import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView
@ -87,11 +93,14 @@ import com.futo.polycentric.core.ApiMethods
import com.google.gson.JsonParser
import com.google.zxing.integration.android.IntentIntegrator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.io.PrintWriter
@ -109,7 +118,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private val HEIGHT_VIDEO_MINIMIZED_DP = 60f;
//Containers
lateinit var rootView : MotionLayout;
lateinit var rootView: MotionLayout;
private lateinit var _overlayContainer: FrameLayout;
private lateinit var _toastView: ToastView;
@ -166,11 +175,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragVideoDetail: VideoDetailFragment;
//State
private val _queue : Queue<Pair<MainFragment, Any?>> = LinkedList();
lateinit var fragCurrent : MainFragment private set;
private val _queue: Queue<Pair<MainFragment, Any?>> = LinkedList();
lateinit var fragCurrent: MainFragment private set;
private var _parameterCurrent: Any? = null;
var fragBeforeOverlay : MainFragment? = null; private set;
var fragBeforeOverlay: MainFragment? = null; private set;
val onNavigated = Event1<MainFragment>();
@ -216,15 +225,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.e("Application", "Uncaught", excp);
//Resolve invocation chains
while(excp is InvocationTargetException || excp is java.lang.RuntimeException) {
while (excp is InvocationTargetException || excp is java.lang.RuntimeException) {
val before = excp;
if(excp is InvocationTargetException)
if (excp is InvocationTargetException)
excp = excp.targetException ?: excp.cause ?: excp;
else if(excp is java.lang.RuntimeException)
else if (excp is java.lang.RuntimeException)
excp = excp.cause ?: excp;
if(excp == before)
if (excp == before)
break;
}
writer.write((excp.message ?: "Empty error") + "\n\n");
@ -246,6 +255,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
@UnstableApi
override fun onCreate(savedInstanceState: Bundle?) {
Logger.i(TAG, "MainActivity Starting");
StateApp.instance.setGlobalContext(this, lifecycleScope);
@ -255,7 +265,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
setContentView(R.layout.activity_main);
setNavigationBarColorAndIcons();
if (Settings.instance.playback.allowVideoToGoUnderCutout)
window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
window.attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
runBlocking {
StatePlatform.instance.updateAvailableClients(this@MainActivity);
@ -329,10 +340,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
updateSegmentPaddings();
};
_fragVideoDetail.onTransitioning.subscribe {
if(it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED)
_fragContainerOverlay.elevation = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics);
if (it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED)
_fragContainerOverlay.elevation =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics);
else
_fragContainerOverlay.elevation = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
_fragContainerOverlay.elevation =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
}
_fragVideoDetail.onCloseEvent.subscribe {
@ -349,40 +362,39 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_buttonIncognito.alpha = 0f;
StateApp.instance.privateModeChanged.subscribe {
//Messing with visibility causes some issues with layout ordering?
if(it) {
if (it) {
_buttonIncognito.elevation = 99f;
_buttonIncognito.alpha = 1f;
}
else {
} else {
_buttonIncognito.elevation = -99f;
_buttonIncognito.alpha = 0f;
}
}
_buttonIncognito.setOnClickListener {
if(!StateApp.instance.privateMode)
if (!StateApp.instance.privateMode)
return@setOnClickListener;
UIDialogs.showDialog(this, R.drawable.ic_disabled_visible_purple, "Disable Privacy Mode",
UIDialogs.showDialog(
this, R.drawable.ic_disabled_visible_purple, "Disable Privacy Mode",
"Do you want to disable privacy mode? New videos will be tracked again.", null, 0,
UIDialogs.Action("Cancel", {
StateApp.instance.setPrivacyMode(true);
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Disable", {
StateApp.instance.setPrivacyMode(false);
}, UIDialogs.ActionStyle.DANGEROUS));
}, UIDialogs.ActionStyle.DANGEROUS)
);
};
_fragVideoDetail.onFullscreenChanged.subscribe {
Logger.i(TAG, "onFullscreenChanged ${it}");
if(it) {
if (it) {
_buttonIncognito.elevation = -99f;
_buttonIncognito.alpha = 0f;
}
else {
if(StateApp.instance.privateMode) {
} else {
if (StateApp.instance.privateMode) {
_buttonIncognito.elevation = 99f;
_buttonIncognito.alpha = 1f;
}
else {
} else {
_buttonIncognito.elevation = -99f;
_buttonIncognito.alpha = 0f;
}
@ -395,7 +407,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return@subscribe;
}
if(_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
if (fragCurrent !is VideoDetailFragment) {
val toPlay = StatePlayer.instance.getCurrentQueueItem();
navigate(_fragVideoDetail, toPlay);
@ -443,11 +455,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragSubGroupList.topBar = _fragTopBarAdd;
_fragBrowser.topBar = _fragTopBarNavigation;
fragCurrent = _fragMainHome;
val defaultTab = Settings.instance.tabs.mapNotNull {
val buttonDefinition = MenuBottomBarFragment.buttonDefinitions.firstOrNull { bd -> it.id == bd.id };
val buttonDefinition =
MenuBottomBarFragment.buttonDefinitions.firstOrNull { bd -> it.id == bd.id };
if (buttonDefinition == null) {
return@mapNotNull null;
} else {
@ -506,7 +519,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//startActivity(Intent(this, TestActivity::class.java));
val sharedPreferences = getSharedPreferences("GrayjayFirstBoot", Context.MODE_PRIVATE)
// updates the requestedOrientation based on user settings
_fragVideoDetail.updateOrientation()
val sharedPreferences =
getSharedPreferences("GrayjayFirstBoot", Context.MODE_PRIVATE)
val isFirstBoot = sharedPreferences.getBoolean("IsFirstBoot", true)
if (isFirstBoot) {
UIDialogs.showConfirmationDialog(this, getString(R.string.do_you_want_to_see_the_tutorials_you_can_find_them_at_any_time_through_the_more_button), {
@ -515,6 +532,64 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
}
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
val subscriptionsThreshold = 20
if (
submissionStatus.value == ""
&& StateApp.instance.getCurrentNetworkState() != StateApp.NetworkState.DISCONNECTED
&& numSubscriptions >= subscriptionsThreshold
) {
UIDialogs.showDialog(
this,
R.drawable.ic_internet,
getString(R.string.contribute_personal_subscriptions_list),
getString(R.string.contribute_personal_subscriptions_list_description),
null,
0,
UIDialogs.Action("Cancel", {
submissionStatus.setAndSave("dismissed")
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Upload", {
submissionStatus.setAndSave("submitted")
GlobalScope.launch(Dispatchers.IO) {
@Serializable
data class CreatorInfo(val pluginId: String, val url: String)
val subscriptions =
StateSubscriptions.instance.getSubscriptions().map { original ->
CreatorInfo(
pluginId = original.channel.id.pluginId ?: "",
url = original.channel.url
)
}
val json = Json.encodeToString(subscriptions)
val url = "https://data.grayjay.app/donate-subscription-list"
val client = ManagedHttpClient();
val headers = hashMapOf(
"Content-Type" to "application/json"
)
try {
val response = client.post(url, json, headers)
// if it failed retry one time
if (!response.isOk) {
client.post(url, json, headers)
}
} catch (e: Exception) {
Logger.i(TAG, "Failed to submit subscription list.", e)
}
}
}, UIDialogs.ActionStyle.PRIMARY)
)
}
}
/*
@ -579,39 +654,45 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
private fun handleIntent(intent: Intent?) {
if(intent == null)
if (intent == null)
return;
Logger.i(TAG, "handleIntent started by " + intent.action);
var targetData: String? = null;
when(intent.action) {
when (intent.action) {
Intent.ACTION_SEND -> {
targetData = intent.getStringExtra(Intent.EXTRA_STREAM) ?: intent.getStringExtra(Intent.EXTRA_TEXT);
targetData = intent.getStringExtra(Intent.EXTRA_STREAM)
?: intent.getStringExtra(Intent.EXTRA_TEXT);
Logger.i(TAG, "Share Received: " + targetData);
}
Intent.ACTION_VIEW -> {
targetData = intent.dataString
if(!targetData.isNullOrEmpty()) {
if (!targetData.isNullOrEmpty()) {
Logger.i(TAG, "View Received: " + targetData);
}
}
"VIDEO" -> {
val url = intent.getStringExtra("VIDEO");
navigate(_fragVideoDetail, url);
}
"IMPORT_OPTIONS" -> {
UIDialogs.showImportOptionsDialog(this);
}
"ACTION" -> {
val action = intent.getStringExtra("ACTION");
StateDeveloper.instance.testState = "TestPlayback";
StateDeveloper.instance.testPlayback();
}
"TAB" -> {
when(intent.getStringExtra("TAB")){
when (intent.getStringExtra("TAB")) {
"Sources" -> {
runBlocking {
StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed..
@ -622,7 +703,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
navigate(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
Pair("grayjay") { req ->
StateApp.instance.contextOrNull?.let {
if(it is MainActivity) {
if (it is MainActivity) {
runBlocking {
it.handleUrlAll(req.url.toString());
}
@ -641,8 +722,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
handleUrlAll(targetData)
}
}
}
catch(ex: Throwable) {
} catch (ex: Throwable) {
UIDialogs.showGeneralErrorDialog(this, getString(R.string.failed_to_handle_file), ex);
}
}
@ -651,35 +731,31 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
val uri = Uri.parse(url)
when (uri.scheme) {
"grayjay" -> {
if(url.startsWith("grayjay://license/")) {
if(StatePayment.instance.setPaymentLicenseUrl(url))
{
if (url.startsWith("grayjay://license/")) {
if (StatePayment.instance.setPaymentLicenseUrl(url)) {
UIDialogs.showDialogOk(this, R.drawable.ic_check, getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required));
if(fragCurrent is BuyFragment)
if (fragCurrent is BuyFragment)
closeSegment(fragCurrent);
}
else
} else
UIDialogs.toast(getString(R.string.invalid_license_format));
}
else if(url.startsWith("grayjay://plugin/")) {
} else if (url.startsWith("grayjay://plugin/")) {
val intent = Intent(this, AddSourceActivity::class.java).apply {
data = Uri.parse(url.substring("grayjay://plugin/".length));
};
startActivity(intent);
}
else if(url.startsWith("grayjay://video/")) {
} else if (url.startsWith("grayjay://video/")) {
val videoUrl = url.substring("grayjay://video/".length);
navigate(_fragVideoDetail, videoUrl);
}
else if(url.startsWith("grayjay://channel/")) {
} else if (url.startsWith("grayjay://channel/")) {
val channelUrl = url.substring("grayjay://channel/".length);
navigate(_fragMainChannel, channelUrl);
}
}
"content" -> {
if(!handleContent(url, intent.type)) {
if (!handleContent(url, intent.type)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
@ -688,8 +764,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
{ });
}
}
"file" -> {
if(!handleFile(url)) {
if (!handleFile(url)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
@ -698,8 +775,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
{ });
}
}
"polycentric" -> {
if(!handlePolycentric(url)) {
if (!handlePolycentric(url)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
@ -708,8 +786,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
{ });
}
}
"fcast" -> {
if(!handleFCast(url)) {
if (!handleFCast(url)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_cast,
@ -718,6 +797,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
{ });
}
}
else -> {
if (!handleUrl(url)) {
UIDialogs.showSingleButtonDialog(
@ -731,7 +811,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
suspend fun handleUrl(url: String): Boolean {
suspend fun handleUrl(url: String, position: Int = 0): Boolean {
Logger.i(TAG, "handleUrl(url=$url)")
return withContext(Dispatchers.IO) {
@ -739,7 +819,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (StatePlatform.instance.hasEnabledVideoClient(url)) {
Logger.i(TAG, "handleUrl(url=$url) found video client");
lifecycleScope.launch(Dispatchers.Main) {
navigate(_fragVideoDetail, url);
if (position > 0)
navigate(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true));
else
navigate(_fragVideoDetail, url);
_fragVideoDetail.maximizeVideoDetail(true);
}
@ -755,7 +838,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) {
Logger.i(TAG, "handleUrl(url=$url) found playlist client");
lifecycleScope.launch(Dispatchers.Main) {
navigate(_fragMainPlaylist, url);
navigate(_fragMainRemotePlaylist, url);
delay(100);
_fragVideoDetail.minimizeVideoDetail();
};
@ -764,24 +847,25 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return@withContext false;
}
}
fun handleContent(file: String, mime: String? = null): Boolean {
Logger.i(TAG, "handleContent(url=$file)");
val data = readSharedContent(file);
if(file.lowercase().endsWith(".json") || mime == "application/json") {
if (file.lowercase().endsWith(".json") || mime == "application/json") {
var recon = String(data);
if(!recon.trim().startsWith("["))
if (!recon.trim().startsWith("["))
return handleUnknownJson(recon);
var reconLines = Json.decodeFromString<List<String>>(recon);
val cacheStr = reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length);
val cacheStr =
reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length);
reconLines = reconLines.filter { !it.startsWith("__CACHE:") }; //TODO: constant prefix
var cache: ImportCache? = null;
try {
if(cacheStr != null)
if (cacheStr != null)
cache = Json.decodeFromString(cacheStr);
}
catch(ex: Throwable) {
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to deserialize cache");
}
@ -790,32 +874,31 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.i(TAG, "Opened shared playlist reconstruction\n${recon}");
handleReconstruction(recon, cache);
return true;
}
else if(file.lowercase().endsWith(".zip") || mime == "application/zip") {
} else if (file.lowercase().endsWith(".zip") || mime == "application/zip") {
StateBackup.importZipBytes(this, lifecycleScope, data);
return true;
}
else if(file.lowercase().endsWith(".txt") || mime == "text/plain") {
} else if (file.lowercase().endsWith(".txt") || mime == "text/plain") {
return handleUnknownText(String(data));
}
return false;
}
fun handleFile(file: String): Boolean {
Logger.i(TAG, "handleFile(url=$file)");
if(file.lowercase().endsWith(".json")) {
if (file.lowercase().endsWith(".json")) {
var recon = String(readSharedFile(file));
if(!recon.startsWith("["))
if (!recon.startsWith("["))
return handleUnknownJson(recon);
var reconLines = Json.decodeFromString<List<String>>(recon);
val cacheStr = reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length);
val cacheStr =
reconLines.find { it.startsWith("__CACHE:") }?.substring("__CACHE:".length);
reconLines = reconLines.filter { !it.startsWith("__CACHE:") }; //TODO: constant prefix
var cache: ImportCache? = null;
try {
if(cacheStr != null)
if (cacheStr != null)
cache = Json.decodeFromString(cacheStr);
}
catch(ex: Throwable) {
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to deserialize cache");
}
recon = reconLines.joinToString("\n");
@ -823,19 +906,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.i(TAG, "Opened shared playlist reconstruction\n${recon}");
handleReconstruction(recon, cache);
return true;
}
else if(file.lowercase().endsWith(".zip")) {
} else if (file.lowercase().endsWith(".zip")) {
StateBackup.importZipBytes(this, lifecycleScope, readSharedFile(file));
return true;
}
else if(file.lowercase().endsWith(".txt")) {
} else if (file.lowercase().endsWith(".txt")) {
return handleUnknownText(String(readSharedFile(file)));
}
return false;
}
fun handleReconstruction(recon: String, cache: ImportCache? = null) {
val type = ManagedStore.getReconstructionIdentifier(recon);
val store: ManagedStore<*> = when(type) {
val store: ManagedStore<*> = when (type) {
"Playlist" -> StatePlaylists.instance.playlistStore
else -> {
UIDialogs.toast(getString(R.string.unknown_reconstruction_type) + " ${type}", false);
@ -843,13 +925,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
};
};
val name = when(type) {
"Playlist" -> recon.split("\n").filter { !it.startsWith(ManagedStore.RECONSTRUCTION_HEADER_OPERATOR) }.firstOrNull() ?: type;
val name = when (type) {
"Playlist" -> recon.split("\n")
.filter { !it.startsWith(ManagedStore.RECONSTRUCTION_HEADER_OPERATOR) }
.firstOrNull() ?: type;
else -> type
}
if(!type.isNullOrEmpty()) {
if (!type.isNullOrEmpty()) {
UIDialogs.showImportDialog(this, store, name, listOf(recon), cache) {
}
@ -858,18 +942,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
fun handleUnknownText(text: String): Boolean {
try {
if(text.startsWith("@/Subscription") || text.startsWith("Subscriptions")) {
if (text.startsWith("@/Subscription") || text.startsWith("Subscriptions")) {
val lines = text.split("\n").map { it.trim() }.drop(1).filter { it.isNotEmpty() };
navigate(_fragImportSubscriptions, lines);
return true;
}
}
catch(ex: Throwable) {
} catch (ex: Throwable) {
Logger.e(TAG, ex.message, ex);
UIDialogs.showGeneralErrorDialog(this, getString(R.string.failed_to_parse_text_file), ex);
}
return false;
}
fun handleUnknownJson(json: String): Boolean {
val context = this;
@ -881,8 +965,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return false;//throw IllegalArgumentException("Invalid NewPipe json structure found");
StateBackup.importNewPipeSubs(this, newPipeSubsParsed);
}
catch(ex: Exception) {
} catch (ex: Exception) {
Logger.e(TAG, ex.message, ex);
UIDialogs.showGeneralErrorDialog(context, getString(R.string.failed_to_parse_newpipe_subscriptions), ex);
}
@ -928,7 +1011,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private fun readSharedFile(filePath: String): ByteArray {
val dataFile = File(filePath);
if(!dataFile.exists())
if (!dataFile.exists())
throw IllegalArgumentException("Opened file does not exist or not permitted");
val data = dataFile.readBytes();
return data;
@ -937,13 +1020,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
override fun onBackPressed() {
Logger.i(TAG, "onBackPressed")
if(_fragBotBarMenu.onBackPressed())
if (_fragBotBarMenu.onBackPressed())
return;
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
return;
if(!fragCurrent.onBackPressed())
if (!fragCurrent.onBackPressed())
closeSegment();
}
@ -951,7 +1034,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
super.onUserLeaveHint();
Logger.i(TAG, "onUserLeaveHint")
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED || _fragVideoDetail.state == VideoDetailFragment.State.MINIMIZED)
if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED || _fragVideoDetail.state == VideoDetailFragment.State.MINIMIZED)
_fragVideoDetail.onUserLeaveHint();
}
@ -987,12 +1070,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
fun navigate(segment: MainFragment, parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
Logger.i(TAG, "Navigate to $segment (parameter=$parameter, withHistory=$withHistory, isBack=$isBack)")
if(segment != fragCurrent) {
if(segment is VideoDetailFragment) {
if(_fragContainerVideoDetail.visibility != View.VISIBLE)
if (segment != fragCurrent) {
if (segment is VideoDetailFragment) {
if (_fragContainerVideoDetail.visibility != View.VISIBLE)
_fragContainerVideoDetail.visibility = View.VISIBLE;
when(segment.state) {
when (segment.state) {
VideoDetailFragment.State.MINIMIZED -> segment.maximizeVideoDetail()
VideoDetailFragment.State.CLOSED -> segment.maximizeVideoDetail()
else -> {}
@ -1000,11 +1083,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
segment.onShown(parameter, isBack);
return;
}
fragCurrent.onHide();
if(segment.isMainView) {
if (segment.isMainView) {
var transaction = supportFragmentManager.beginTransaction();
if (segment.topBar != null) {
if (segment.topBar != fragCurrent.topBar) {
@ -1013,8 +1095,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
.replace(R.id.fragment_top_bar, segment.topBar as Fragment);
fragCurrent.topBar?.onHide();
}
}
else if(fragCurrent.topBar != null)
} else if (fragCurrent.topBar != null)
transaction.hide(fragCurrent.topBar as Fragment);
transaction = transaction.replace(R.id.fragment_main, segment);
@ -1022,25 +1103,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (segment.hasBottomBar) {
if (!fragCurrent.hasBottomBar)
transaction = transaction.show(_fragBotBarMenu);
}
else {
if(fragCurrent.hasBottomBar)
} else {
if (fragCurrent.hasBottomBar)
transaction = transaction.hide(_fragBotBarMenu);
}
transaction.commitNow();
} else {
if(!segment.hasBottomBar) {
if (!segment.hasBottomBar) {
supportFragmentManager.beginTransaction()
.hide(_fragBotBarMenu)
.commitNow();
}
}
if(fragCurrent.isHistory && withHistory && _queue.lastOrNull() != fragCurrent)
if (fragCurrent.isHistory && withHistory && _queue.lastOrNull() != fragCurrent)
_queue.add(Pair(fragCurrent, _parameterCurrent));
if(segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory)
if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory)
fragBeforeOverlay = fragCurrent;
@ -1058,12 +1138,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
* If called with a non-null fragment, it will only close if the current fragment is the provided one
*/
fun closeSegment(fragment: MainFragment? = null) {
if(fragment is VideoDetailFragment) {
if (fragment is VideoDetailFragment) {
fragment.onHide();
return;
}
if((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) {
if ((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) {
navigate(fragBeforeOverlay!!, null, false, true);
} else {
val last = _queue.lastOrNull();
@ -1085,8 +1165,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
/**
* Provides the fragment instance for the provided fragment class
*/
inline fun <reified T : Fragment> getFragment() : T {
return when(T::class) {
inline fun <reified T : Fragment> getFragment(): T {
return when (T::class) {
HomeFragment::class -> _fragMainHome as T;
TutorialFragment::class -> _fragMainTutorial as T;
ContentSearchResultsFragment::class -> _fragMainVideoSearchResults as T;
@ -1123,15 +1203,21 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private fun updateSegmentPaddings() {
var paddingBottom = 0f;
if(fragCurrent.hasBottomBar)
if (fragCurrent.hasBottomBar)
paddingBottom += HEIGHT_MENU_DP;
_fragContainerOverlay.setPadding(0,0,0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingBottom - HEIGHT_MENU_DP, resources.displayMetrics).toInt());
_fragContainerOverlay.setPadding(
0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingBottom - HEIGHT_MENU_DP, resources.displayMetrics)
.toInt()
);
if(_fragVideoDetail.state == VideoDetailFragment.State.MINIMIZED)
if (_fragVideoDetail.state == VideoDetailFragment.State.MINIMIZED)
paddingBottom += HEIGHT_VIDEO_MINIMIZED_DP;
_fragContainerMain.setPadding(0,0,0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingBottom, resources.displayMetrics).toInt());
_fragContainerMain.setPadding(
0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingBottom, resources.displayMetrics)
.toInt()
);
}
@ -1147,14 +1233,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
ContextCompat.checkSelfPermission(this, notifPermission) == PackageManager.PERMISSION_GRANTED -> {
}
ActivityCompat.shouldShowRequestPermissionRationale(this, notifPermission) -> {
UIDialogs.showDialog(this, R.drawable.ic_notifications, "Notifications Required",
UIDialogs.showDialog(
this, R.drawable.ic_notifications, "Notifications Required",
reason, null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Enable", {
requestPermissionLauncher.launch(notifPermission);
}, UIDialogs.ActionStyle.PRIMARY));
}, UIDialogs.ActionStyle.PRIMARY)
);
}
else -> {
requestPermissionLauncher.launch(notifPermission);
}
@ -1166,15 +1256,16 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
fun showAppToast(toast: ToastView.Toast) {
synchronized(_toastQueue) {
_toastQueue.add(toast);
if(_toastJob?.isActive != true)
if (_toastJob?.isActive != true)
_toastJob = lifecycleScope.launch(Dispatchers.Default) {
launchAppToastJob();
};
}
}
private suspend fun launchAppToastJob() {
Logger.i(TAG, "Starting appToast loop");
while(!_toastQueue.isEmpty()) {
while (!_toastQueue.isEmpty()) {
val toast = _toastQueue.poll() ?: continue;
Logger.i(TAG, "Showing next toast (${toast.msg})");
@ -1187,10 +1278,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_toastView.setToastAnimated(toast);
}
}
if(toast.long)
if (toast.long)
delay(5000);
else
delay(3000);
delay(2500);
}
Logger.i(TAG, "Ending appToast loop");
lifecycleScope.launch(Dispatchers.Main) {
@ -1201,18 +1292,19 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//TODO: Only calls last handler due to missing request codes on ActivityResultLaunchers.
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult) -> Unit>();
private var requestCode: Int? = -1;
private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult ->
ActivityResultContracts.StartActivityForResult()
) { result: ActivityResult ->
val handler = synchronized(resultLauncherMap) {
resultLauncherMap.remove(requestCode);
}
if(handler != null)
if (handler != null)
handler(result);
};
override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit) {
override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult) -> Unit) {
synchronized(resultLauncherMap) {
resultLauncherMap[code] = handler;
}
@ -1223,32 +1315,34 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
companion object {
private val TAG = "MainActivity"
fun getTabIntent(context: Context, tab: String) : Intent {
fun getTabIntent(context: Context, tab: String): Intent {
val sourcesIntent = Intent(context, MainActivity::class.java);
sourcesIntent.action = "TAB";
sourcesIntent.putExtra("TAB", tab);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
return sourcesIntent;
}
fun getVideoIntent(context: Context, videoUrl: String) : Intent {
fun getVideoIntent(context: Context, videoUrl: String): Intent {
val sourcesIntent = Intent(context, MainActivity::class.java);
sourcesIntent.action = "VIDEO";
sourcesIntent.putExtra("VIDEO", videoUrl);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
return sourcesIntent;
}
fun getActionIntent(context: Context, action: String) : Intent {
fun getActionIntent(context: Context, action: String): Intent {
val sourcesIntent = Intent(context, MainActivity::class.java);
sourcesIntent.action = "ACTION";
sourcesIntent.putExtra("ACTION", action);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
return sourcesIntent;
}
fun getImportOptionsIntent(context: Context): Intent {
val sourcesIntent = Intent(context, MainActivity::class.java);
sourcesIntent.action = "IMPORT_OPTIONS";
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
return sourcesIntent;
}
}

View file

@ -3,6 +3,7 @@ package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.EditText
import android.widget.ImageButton
import android.widget.LinearLayout
@ -10,15 +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
@ -27,6 +29,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
private lateinit var _buttonHelp: ImageButton;
private lateinit var _profileName: EditText;
private lateinit var _buttonCreate: LinearLayout;
private lateinit var _loader: LoaderView;
private val TAG = "PolycentricCreateProfileActivity";
private var _creating = false;
@ -43,6 +46,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
_buttonHelp = findViewById(R.id.button_help);
_profileName = findViewById(R.id.edit_profile_name);
_buttonCreate = findViewById(R.id.button_create_profile);
_loader = findViewById(R.id.loader);
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish();
};
@ -65,35 +69,49 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
return@setOnClickListener;
}
_profileName.isEnabled = false;
_buttonCreate.visibility = View.GONE;
_loader.start();
_loader.visibility = View.VISIBLE;
lifecycleScope.launch(Dispatchers.IO) {
val processHandle: ProcessHandle;
try {
processHandle = ProcessHandle.create();
Store.instance.addProcessSecret(processHandle.processSecret);
try {
PolycentricStorage.instance.addProcessSecret(processHandle.processSecret)
processHandle = ProcessHandle.create();
Store.instance.addProcessSecret(processHandle.processSecret);
try {
PolycentricStorage.instance.addProcessSecret(processHandle.processSecret)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
}
processHandle.addServer(ApiMethods.SERVER);
processHandle.setUsername(username);
StatePolycentric.instance.setProcessHandle(processHandle);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
Logger.e(TAG, getString(R.string.failed_to_create_profile), e);
return@launch;
} finally {
_creating = false;
}
processHandle.addServer(PolycentricCache.SERVER);
processHandle.setUsername(username);
StatePolycentric.instance.setProcessHandle(processHandle);
} catch (e: Throwable) {
Logger.e(TAG, getString(R.string.failed_to_create_profile), e);
return@launch;
} finally {
_creating = false;
try {
Logger.i(TAG, "Started backfill");
processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, getString(R.string.failed_to_fully_backfill_servers), e);
}
}
try {
Logger.i(TAG, "Started backfill");
processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, getString(R.string.failed_to_fully_backfill_servers), e);
finally {
withContext(Dispatchers.Main) {
_profileName.isEnabled = true;
_buttonCreate.visibility = View.VISIBLE;
_loader.stop();
_loader.visibility = View.GONE;
}
}
withContext(Dispatchers.Main) {

View file

@ -8,6 +8,7 @@ import android.os.Bundle
import android.util.TypedValue
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.ScrollView
import androidx.appcompat.app.AppCompatActivity
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
@ -28,6 +29,7 @@ class PolycentricHomeActivity : AppCompatActivity() {
private lateinit var _buttonNewProfile: BigButton;
private lateinit var _buttonImportProfile: BigButton;
private lateinit var _layoutButtons: LinearLayout;
private lateinit var _scroll: ScrollView;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
@ -42,6 +44,7 @@ class PolycentricHomeActivity : AppCompatActivity() {
_buttonNewProfile = findViewById(R.id.button_new_profile);
_buttonImportProfile = findViewById(R.id.button_import_profile);
_layoutButtons = findViewById(R.id.layout_buttons);
_scroll = findViewById(R.id.scroll);
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish();
};
@ -78,6 +81,7 @@ class PolycentricHomeActivity : AppCompatActivity() {
_layoutButtons.addView(profileButton, 0);
}
_scroll.invalidate();
_buttonHelp.setOnClickListener {
startActivity(Intent(this, PolycentricWhyActivity::class.java));

View file

@ -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();

View file

@ -21,18 +21,20 @@ 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
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
import com.github.dhaval2404.imagepicker.ImagePicker
@ -47,6 +49,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
private lateinit var _buttonHelp: ImageButton;
private lateinit var _editName: EditText;
private lateinit var _buttonExport: BigButton;
private lateinit var _buttonOpenHarborProfile: BigButton;
private lateinit var _buttonLogout: BigButton;
private lateinit var _buttonDelete: BigButton;
private lateinit var _username: String;
@ -68,10 +71,14 @@ class PolycentricProfileActivity : AppCompatActivity() {
_imagePolycentric = findViewById(R.id.image_polycentric);
_editName = findViewById(R.id.edit_profile_name);
_buttonExport = findViewById(R.id.button_export);
_buttonOpenHarborProfile = findViewById(R.id.button_open_harbor_profile);
_buttonLogout = findViewById(R.id.button_logout);
_buttonDelete = findViewById(R.id.button_delete);
_loaderOverlay = findViewById(R.id.loader_overlay);
_textSystem = findViewById(R.id.text_system)
findViewById<TextView>(R.id.text_cta2).setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://harbor.social")))
}
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
saveIfRequired();
finish();
@ -92,6 +99,16 @@ class PolycentricProfileActivity : AppCompatActivity() {
startActivity(Intent(this, PolycentricBackupActivity::class.java));
};
_buttonOpenHarborProfile.onClick.subscribe {
val processHandle = StatePolycentric.instance.processHandle!!;
processHandle?.let {
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(it.system));
val url = it.system.systemToURLInfoSystemLinkUrl(systemState.servers.asIterable());
val navUrl = "https://harbor.social/" + url.substring("polycentric://".length)
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl)))
}
}
_buttonLogout.onClick.subscribe {
StatePolycentric.instance.setProcessHandle(null);
startActivity(Intent(this, PolycentricHomeActivity::class.java));
@ -108,6 +125,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
StatePolycentric.instance.setProcessHandle(null);
Store.instance.removeProcessSecret(processHandle.system);
PolycentricStorage.instance.removeProcessSecret(processHandle.system);
startActivity(Intent(this, PolycentricHomeActivity::class.java));
finish();
});
@ -127,7 +145,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
lifecycleScope.launch(Dispatchers.IO) {
try {
processHandle.fullyBackfillClient(PolycentricCache.SERVER)
processHandle.fullyBackfillClient(ApiMethods.SERVER)
withContext(Dispatchers.Main) {
updateUI();

View file

@ -0,0 +1,140 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.sync.internal.LinkType
import com.futo.platformplayer.sync.internal.SyncSession
import com.futo.platformplayer.views.sync.SyncDeviceView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class SyncHomeActivity : AppCompatActivity() {
private lateinit var _layoutDevices: LinearLayout
private lateinit var _layoutEmpty: LinearLayout
private val _viewMap: MutableMap<String, SyncDeviceView> = mutableMapOf()
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_sync_home)
setNavigationBarColorAndIcons()
_layoutDevices = findViewById(R.id.layout_devices)
_layoutEmpty = findViewById(R.id.layout_empty)
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish()
}
findViewById<LinearLayout>(R.id.button_link_new_device).setOnClickListener {
startActivity(Intent(this@SyncHomeActivity, SyncPairActivity::class.java))
}
findViewById<LinearLayout>(R.id.button_show_pairing_code).setOnClickListener {
startActivity(Intent(this@SyncHomeActivity, SyncShowPairingCodeActivity::class.java))
}
initializeDevices()
StateSync.instance.deviceUpdatedOrAdded.subscribe(this) { publicKey, session ->
lifecycleScope.launch(Dispatchers.Main) {
val view = _viewMap[publicKey]
if (!session.isAuthorized) {
if (view != null) {
_layoutDevices.removeView(view)
_viewMap.remove(publicKey)
}
return@launch
}
if (view == null) {
val syncDeviceView = SyncDeviceView(this@SyncHomeActivity)
syncDeviceView.onRemove.subscribe {
StateApp.instance.scopeOrNull?.launch {
StateSync.instance.delete(publicKey)
}
}
val v = updateDeviceView(syncDeviceView, publicKey, session)
_layoutDevices.addView(v, 0)
_viewMap[publicKey] = v
} else {
updateDeviceView(view, publicKey, session)
}
updateEmptyVisibility()
}
}
StateSync.instance.deviceRemoved.subscribe(this) {
lifecycleScope.launch(Dispatchers.Main) {
val view = _viewMap[it]
if (view != null) {
_layoutDevices.removeView(view)
_viewMap.remove(it)
}
updateEmptyVisibility()
}
}
}
override fun onDestroy() {
super.onDestroy()
StateSync.instance.deviceUpdatedOrAdded.remove(this)
StateSync.instance.deviceRemoved.remove(this)
}
private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView {
val connected = session?.connected ?: false
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
}
private fun updateEmptyVisibility() {
if (_viewMap.isNotEmpty()) {
_layoutEmpty.visibility = View.GONE
} else {
_layoutEmpty.visibility = View.VISIBLE
}
}
private fun initializeDevices() {
_layoutDevices.removeAllViews()
for (publicKey in StateSync.instance.getAll()) {
val syncDeviceView = SyncDeviceView(this)
syncDeviceView.onRemove.subscribe {
StateApp.instance.scopeOrNull?.launch {
StateSync.instance.delete(publicKey)
}
}
val view = updateDeviceView(syncDeviceView, publicKey, StateSync.instance.getSession(publicKey))
_layoutDevices.addView(view)
_viewMap[publicKey] = view
}
updateEmptyVisibility()
}
companion object {
private const val TAG = "SyncHomeActivity"
}
}

View file

@ -0,0 +1,148 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.os.Bundle
import android.util.Base64
import android.view.View
import android.widget.EditText
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.sync.internal.SyncDeviceInfo
import com.google.zxing.integration.android.IntentIntegrator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
class SyncPairActivity : AppCompatActivity() {
private lateinit var _editCode: EditText
private lateinit var _layoutPairing: LinearLayout
private lateinit var _textPairingStatus: TextView
private lateinit var _layoutPairingSuccess: LinearLayout
private lateinit var _layoutPairingError: LinearLayout
private lateinit var _textError: TextView
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
scanResult?.let {
if (it.contents != null) {
_editCode.text.clear()
_editCode.text.append(it.contents)
pair(it.contents)
}
}
}
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_sync_pair)
setNavigationBarColorAndIcons()
_editCode = findViewById(R.id.edit_code)
_layoutPairing = findViewById(R.id.layout_pairing)
_textPairingStatus = findViewById(R.id.text_pairing_status)
_layoutPairingSuccess = findViewById(R.id.layout_pairing_success)
_layoutPairingError = findViewById(R.id.layout_pairing_error)
_textError = findViewById(R.id.text_error)
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish()
}
findViewById<LinearLayout>(R.id.button_scan_qr).setOnClickListener {
val integrator = IntentIntegrator(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt(getString(R.string.scan_a_qr_code))
integrator.setOrientationLocked(true);
integrator.setCameraId(0)
integrator.setBeepEnabled(false)
integrator.setBarcodeImageEnabled(true)
integrator.setCaptureActivity(QRCaptureActivity::class.java);
_qrCodeResultLauncher.launch(integrator.createScanIntent())
}
findViewById<LinearLayout>(R.id.button_link_new_device).setOnClickListener {
pair(_editCode.text.toString())
}
_layoutPairingSuccess.setOnClickListener {
_layoutPairingSuccess.visibility = View.GONE
}
_layoutPairingError.setOnClickListener {
_layoutPairingError.visibility = View.GONE
}
_layoutPairingSuccess.visibility = View.GONE
_layoutPairingError.visibility = View.GONE
}
fun pair(url: String) {
try {
_layoutPairing.visibility = View.VISIBLE
_textPairingStatus.text = "Parsing text..."
if (!url.startsWith("grayjay://sync/")) {
throw Exception("Not a valid URL: $url")
}
val deviceInfo: SyncDeviceInfo = Json.decodeFromString<SyncDeviceInfo>(Base64.decode(url.substring("grayjay://sync/".length), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP).decodeToString())
if (StateSync.instance.isAuthorized(deviceInfo.publicKey)) {
throw Exception("This device is already paired")
}
_textPairingStatus.text = "Connecting..."
lifecycleScope.launch(Dispatchers.IO) {
try {
StateSync.instance.connect(deviceInfo) { complete, message ->
lifecycleScope.launch(Dispatchers.Main) {
if (complete != null && complete) {
_layoutPairingSuccess.visibility = View.VISIBLE
_layoutPairing.visibility = View.GONE
} else {
_textPairingStatus.text = message
}
}
}
} catch (e: Throwable) {
withContext(Dispatchers.Main) {
_layoutPairingError.visibility = View.VISIBLE
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)
}
}
}
} catch(e: Throwable) {
_layoutPairingError.visibility = View.VISIBLE
_textError.text = e.message
_layoutPairing.visibility = View.GONE
Logger.e(TAG, "Failed to pair", e)
} finally {
_layoutPairing.visibility = View.GONE
}
}
companion object {
private const val TAG = "SyncPairActivity"
}
}

View file

@ -0,0 +1,142 @@
package com.futo.platformplayer.activities
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.os.Bundle
import android.util.Base64
import android.util.TypedValue
import android.view.View
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.sync.internal.SyncDeviceInfo
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
import com.google.zxing.common.BitMatrix
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.net.NetworkInterface
class SyncShowPairingCodeActivity : AppCompatActivity() {
private lateinit var _textCode: TextView
private lateinit var _imageQR: ImageView
private lateinit var _textQR: TextView
private var _code: String? = null
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onDestroy() {
super.onDestroy()
activity = null
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
activity = this
setContentView(R.layout.activity_sync_show_pairing_code)
setNavigationBarColorAndIcons()
_textCode = findViewById(R.id.text_code)
_imageQR = findViewById(R.id.image_qr)
_textQR = findViewById(R.id.text_scan_qr)
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish()
}
findViewById<LinearLayout>(R.id.button_copy).setOnClickListener {
val code = _code ?: return@setOnClickListener
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager;
val clip = ClipData.newPlainText(getString(R.string.copied_text), code);
clipboard.setPrimaryClip(clip);
UIDialogs.toast(this, "Copied to clipboard")
}
val ips = getIPs()
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}"
setCode(url)
}
fun setCode(code: String?) {
_code = code
_textCode.text = code
if (code == null) {
_imageQR.visibility = View.INVISIBLE
_textQR.visibility = View.INVISIBLE
return
}
try {
val dimension = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics).toInt()
val qrCodeBitmap = generateQRCode(code, dimension, dimension)
_imageQR.setImageBitmap(qrCodeBitmap)
_imageQR.visibility = View.VISIBLE
_textQR.visibility = View.VISIBLE
} catch (e: Exception) {
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e)
_imageQR.visibility = View.INVISIBLE
_textQR.visibility = View.INVISIBLE
}
}
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height);
return bitMatrixToBitmap(bitMatrix);
}
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
val width = matrix.width;
val height = matrix.height;
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565);
for (x in 0 until width) {
for (y in 0 until height) {
bmp.setPixel(x, y, if (matrix[x, y]) Color.BLACK else Color.WHITE);
}
}
return bmp;
}
private fun getIPs(): List<String> {
val ips = arrayListOf<String>()
for (intf in NetworkInterface.getNetworkInterfaces()) {
for (addr in intf.inetAddresses) {
if (addr.isLoopbackAddress) {
continue
}
if (addr.address.size != 4) {
continue
}
addr.hostAddress?.let { ips.add(it) }
}
}
return ips
}
companion object {
private const val TAG = "SyncShowPairingCodeActivity"
var activity: SyncShowPairingCodeActivity? = null
private set
}
}

View file

@ -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());

View file

@ -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
}

View file

@ -4,6 +4,6 @@ import kotlinx.serialization.json.Json
class Serializer {
companion object {
val json = Json { ignoreUnknownKeys = true; encodeDefaults = true; };
val json = Json { ignoreUnknownKeys = true; encodeDefaults = true; coerceInputValues = true };
}
}

View file

@ -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";

View file

@ -0,0 +1,63 @@
package com.futo.platformplayer.api.media.models.comments
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
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.ratings.RatingType
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.logging.Logger
import kotlinx.coroutines.Deferred
import java.time.OffsetDateTime
class LazyComment: IPlatformComment {
private var _commentDeferred: Deferred<IPlatformComment>;
private var _commentLoaded: IPlatformComment? = null;
private var _commentException: Throwable? = null;
override val contextUrl: String
get() = _commentLoaded?.contextUrl ?: "";
override val author: PlatformAuthorLink
get() = _commentLoaded?.author ?: PlatformAuthorLink.UNKNOWN;
override val message: String
get() = _commentLoaded?.message ?: "";
override val rating: IRating
get() = _commentLoaded?.rating ?: RatingLikes(0);
override val date: OffsetDateTime?
get() = _commentLoaded?.date ?: OffsetDateTime.MIN;
override val replyCount: Int?
get() = _commentLoaded?.replyCount ?: 0;
val isAvailable: Boolean get() = _commentLoaded != null;
private var _uiHandler: ((LazyComment)->Unit)? = null;
constructor(commentDeferred: Deferred<IPlatformComment>) {
_commentDeferred = commentDeferred;
_commentDeferred.invokeOnCompletion {
if(it == null) {
_commentLoaded = commentDeferred.getCompleted();
Logger.i("LazyComment", "Resolved comment");
}
else {
_commentException = it;
Logger.e("LazyComment", "Resolving comment failed: ${it.message}", it);
}
_uiHandler?.invoke(this);
}
}
fun getUnderlyingComment(): IPlatformComment? {
return _commentLoaded;
}
fun setUIHandler(handler: (LazyComment)->Unit){
_uiHandler = handler;
}
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment>? {
return _commentLoaded?.getReplies(client);
}
}

View file

@ -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 {

View file

@ -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();

View file

@ -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;

View file

@ -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 {

View file

@ -8,4 +8,5 @@ interface IAudioSource {
val language : String;
val duration : Long?;
val priority: Boolean;
val original: Boolean;
}

View file

@ -1,6 +1,3 @@
package com.futo.platformplayer.api.media.models.streams.sources
interface IAudioUrlWidevineSource : IAudioUrlSource {
val bearerToken: String
val licenseUri: String
}
interface IAudioUrlWidevineSource : IAudioUrlSource, IWidevineSource

View file

@ -0,0 +1,5 @@
package com.futo.platformplayer.api.media.models.streams.sources
interface IDashManifestWidevineSource : IWidevineSource {
val url: String
}

View file

@ -0,0 +1,3 @@
package com.futo.platformplayer.api.media.models.streams.sources
interface IVideoUrlWidevineSource : IVideoUrlSource, IWidevineSource

View file

@ -0,0 +1,9 @@
package com.futo.platformplayer.api.media.models.streams.sources
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
interface IWidevineSource {
val licenseUri: String
val hasLicenseRequestExecutor: Boolean
fun getLicenseRequestExecutor(): JSRequestExecutor?
}

View file

@ -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
);

View file

@ -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
);

View file

@ -13,4 +13,6 @@ interface IPlatformVideo : IPlatformContent {
val viewCount: Long;
val isLive : Boolean;
val isShort: Boolean;
}

View file

@ -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)
override val datetime: OffsetDateTime?,
@JsonNames("datetime", "dateTime")
override val datetime: OffsetDateTime? = null,
override val url: String,
override val shareUrl: 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,

View file

@ -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;

View file

@ -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(),
@ -50,7 +51,9 @@ class SourcePluginConfig(
var primaryClaimFieldType: Int? = null,
var developerSubmitUrl: String? = null,
var allowAllHttpHeaderAccess: Boolean = false,
var maxDownloadParallelism: Int = 0
var maxDownloadParallelism: Int = 0,
var reduceFunctionsInLimitedVersion: Boolean = false,
var changelog: HashMap<String, List<String>>? = null
) : IV8PluginConfig {
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
@ -100,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;
@ -128,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."));
@ -177,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);

View file

@ -38,7 +38,7 @@ class JSHttpClient : ManagedHttpClient {
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, config: SourcePluginConfig? = null) : super(
//Temporary ugly solution for DevPortal proxy support
(if(jsClient?.config?.id == StateDeveloper.DEV_ID && StateDeveloper.instance.devProxy != null)
(if((jsClient?.config?.id == StateDeveloper.DEV_ID || jsClient == null) && StateDeveloper.instance.devProxy != null)
OkHttpClient.Builder().proxy(Proxy(Proxy.Type.HTTP,
InetSocketAddress(StateDeveloper.instance.devProxy!!.url, StateDeveloper.instance.devProxy!!.port)
))

View file

@ -71,6 +71,8 @@ abstract class JSPager<T> : IPager<T> {
warnIfMainThread("JSPager.getResults");
val items = pager.getOrThrow<V8ValueArray>(config, "results", "JSPager");
if(items.v8Runtime.isDead || items.v8Runtime.isClosed)
throw IllegalStateException("Runtime closed");
val newResults = items.toArray()
.map { convertResult(it as V8ValueObject) }
.toList();

View file

@ -10,6 +10,7 @@ import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.ReusablePager
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.models.Playlist
import java.util.UUID
class JSPlaylistDetails: JSPlaylist, IPlatformPlaylistDetails {
override val contents: IPager<IPlatformVideo>;
@ -37,6 +38,6 @@ class JSPlaylistDetails: JSPlaylist, IPlatformPlaylistDetails {
onProgress?.invoke(videos.size);
}
return Playlist(id.toString(), name, videos.map { SerializedPlatformVideo.fromVideo(it)});
return Playlist(UUID.randomUUID().toString(), name, videos.map { SerializedPlatformVideo.fromVideo(it)});
}
}

View file

@ -42,7 +42,7 @@ class JSRequestExecutor {
//TODO: Executor properties?
@Throws(ScriptException::class)
open fun executeRequest(url: String, headers: Map<String, String>): ByteArray {
open fun executeRequest(method: String, url: String, body: ByteArray?, headers: Map<String, String>): ByteArray {
if (_executor.isClosed)
throw IllegalStateException("Executor object is closed");
@ -53,7 +53,7 @@ class JSRequestExecutor {
"[${_config.name}] JSRequestExecutor",
"builder.modifyRequest()"
) {
_executor.invoke("executeRequest", url, headers);
_executor.invoke("executeRequest", url, headers, method, body);
} as V8Value;
}
else V8Plugin.catchScriptErrors<Any>(
@ -61,7 +61,7 @@ class JSRequestExecutor {
"[${_config.name}] JSRequestExecutor",
"builder.modifyRequest()"
) {
_executor.invoke("executeRequest", url, headers);
_executor.invoke("executeRequest", url, headers, method, body);
} as V8Value;
try {

View file

@ -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;
}
}

View file

@ -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 {

View file

@ -3,22 +3,39 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlWidevineSource
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrThrow
class JSAudioUrlWidevineSource : JSAudioUrlSource, IAudioUrlWidevineSource {
override val bearerToken: String
override val licenseUri: String
override val hasLicenseRequestExecutor: Boolean
@Suppress("ConvertSecondaryConstructorToPrimary")
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin, obj) {
val contextName = "JSAudioUrlWidevineSource"
val config = plugin.config
bearerToken = _obj.getOrThrow(config, "bearerToken", contextName)
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
}
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invoke("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return null
return JSRequestExecutor(_plugin, result)
}
override fun toString(): String {
val url = getAudioUrl()
return "(name=$name, container=$container, bitrate=$bitrate, codec=$codec, url=$url, language=$language, duration=$duration, bearerToken=$bearerToken, licenseUri=$licenseUri)"
return "(name=$name, container=$container, bitrate=$bitrate, codec=$codec, url=$url, language=$language, duration=$duration, hasLicenseRequestExecutor=${hasLicenseRequestExecutor}, licenseUri=$licenseUri)"
}
}

View file

@ -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;
}
}

View file

@ -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 {

View file

@ -0,0 +1,60 @@
package com.futo.platformplayer.api.media.platforms.js.models.sources
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestWidevineSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrNull
import com.futo.platformplayer.getOrThrow
class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
IDashManifestWidevineSource, JSSource {
override val width: Int = 0
override val height: Int = 0
override val container: String = "application/dash+xml"
override val codec: String = "Dash"
override val name: String
override val bitrate: Int? = null
override val url: String
override val duration: Long
override var priority: Boolean = false
override val licenseUri: String
override val hasLicenseRequestExecutor: Boolean
@Suppress("ConvertSecondaryConstructorToPrimary")
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
val contextName = "DashWidevineSource"
val config = plugin.config
name = _obj.getOrThrow(config, "name", contextName)
url = _obj.getOrThrow(config, "url", contextName)
duration = _obj.getOrThrow(config, "duration", contextName)
priority = obj.getOrNull(config, "priority", contextName) ?: false
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
}
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSDashManifestWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invoke("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return null
return JSRequestExecutor(_plugin, result)
}
override fun getVideoUrl(): String {
return url
}
}

View file

@ -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;
}

View file

@ -98,18 +98,22 @@ abstract class JSSource {
const val TYPE_AUDIO_WITH_METADATA = "AudioUrlRangeSource";
const val TYPE_VIDEO_WITH_METADATA = "VideoUrlRangeSource";
const val TYPE_DASH = "DashSource";
const val TYPE_DASH_WIDEVINE = "DashWidevineSource";
const val TYPE_DASH_RAW = "DashRawSource";
const val TYPE_DASH_RAW_AUDIO = "DashRawAudioSource";
const val TYPE_HLS = "HLSSource";
const val TYPE_AUDIOURL_WIDEVINE = "AudioUrlWidevineSource"
const val TYPE_VIDEOURL_WIDEVINE = "VideoUrlWidevineSource"
fun fromV8VideoNullable(plugin: JSClient, obj: V8Value?) : IVideoSource? = obj.orNull { fromV8Video(plugin, it as V8ValueObject) };
fun fromV8Video(plugin: JSClient, obj: V8ValueObject) : IVideoSource? {
val type = obj.getString("plugin_type");
return when(type) {
TYPE_VIDEOURL -> JSVideoUrlSource(plugin, obj);
TYPE_VIDEOURL_WIDEVINE -> JSVideoUrlWidevineSource(plugin, obj);
TYPE_VIDEO_WITH_METADATA -> JSVideoUrlRangeSource(plugin, obj);
TYPE_HLS -> fromV8HLS(plugin, obj);
TYPE_DASH_WIDEVINE -> JSDashManifestWidevineSource(plugin, obj)
TYPE_DASH -> fromV8Dash(plugin, obj);
TYPE_DASH_RAW -> fromV8DashRaw(plugin, obj);
else -> {

View file

@ -0,0 +1,41 @@
package com.futo.platformplayer.api.media.platforms.js.models.sources
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlWidevineSource
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrThrow
class JSVideoUrlWidevineSource : JSVideoUrlSource, IVideoUrlWidevineSource {
override val licenseUri: String
override val hasLicenseRequestExecutor: Boolean
@Suppress("ConvertSecondaryConstructorToPrimary")
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin, obj) {
val contextName = "JSAudioUrlWidevineSource"
val config = plugin.config
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
}
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invoke("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return null
return JSRequestExecutor(_plugin, result)
}
override fun toString(): String {
val url = getVideoUrl()
return "(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url, hasLicenseRequestExecutor=$hasLicenseRequestExecutor, licenseUri=$licenseUri)"
}
}

View file

@ -0,0 +1,5 @@
package com.futo.platformplayer.api.media.platforms.local
class LocalClient {
//TODO
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}

View file

@ -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>;

View file

@ -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>;
}

View file

@ -88,7 +88,8 @@ class DashBuilder : XMLBuilder {
fun withRepresentationOnDemand(id: String, subtitleSource: ISubtitleSource, subtitleUrl: String) {
withRepresentation(id, mapOf(
Pair("mimeType", subtitleSource.format ?: "text/vtt"),
Pair("startWithSAP", "1"),
Pair("default", "true"),
Pair("lang", "en"),
Pair("bandwidth", "1000")
)) {
it.withBaseURL(subtitleUrl)
@ -151,7 +152,7 @@ class DashBuilder : XMLBuilder {
)
) {
//TODO: Verify if & really should be replaced like this?
it.withRepresentationOnDemand("1", subtitleSource, subtitleUrl.replace("&", "&amp;"))
it.withRepresentationOnDemand("caption_en", subtitleSource, subtitleUrl.replace("&", "&amp;"))
}
}
//Video
@ -164,7 +165,7 @@ class DashBuilder : XMLBuilder {
Pair("subsegmentStartsWithSAP", "1")
)
) {
it.withRepresentationOnDemand("1", vidSource, vidUrl.replace("&", "&amp;"));
it.withRepresentationOnDemand("2", vidSource, vidUrl.replace("&", "&amp;"));
}
}

View file

@ -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

View file

@ -1,5 +1,6 @@
package com.futo.platformplayer.casting
import android.app.AlertDialog
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
@ -9,6 +10,7 @@ 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
@ -64,7 +66,7 @@ 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();
@ -239,6 +241,9 @@ class StateCasting {
Logger.i(TAG, "CastingService stopped.")
}
private val _castingDialogLock = Any();
private var _currentDialog: AlertDialog? = null;
@Synchronized
fun connectDevice(device: CastingDevice) {
if (activeDevice == device)
@ -272,10 +277,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;
}
}
}
}
}
};
@ -1245,7 +1281,7 @@ class StateCasting {
val videoExecutor = _videoExecutor;
if (videoExecutor != null) {
val data = videoExecutor.executeRequest(originalUrl, httpContext.headers)
val data = videoExecutor.executeRequest("GET", originalUrl, null, httpContext.headers)
httpContext.respondBytes(200, HttpHeaders().apply {
put("Content-Type", mediaType)
}, data);
@ -1263,7 +1299,7 @@ class StateCasting {
val audioExecutor = _audioExecutor;
if (audioExecutor != null) {
val data = audioExecutor.executeRequest(originalUrl, httpContext.headers)
val data = audioExecutor.executeRequest("GET", originalUrl, null, httpContext.headers)
httpContext.respondBytes(200, HttpHeaders().apply {
put("Content-Type", mediaType)
}, data);

View file

@ -22,6 +22,7 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
private lateinit var _buttonCancel: ImageButton;
private lateinit var _editPassword: EditText;
private lateinit var _editPassword2: EditText;
private lateinit var _inputMethodManager: InputMethodManager;
@ -34,6 +35,7 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
_buttonStop = findViewById(R.id.button_stop);
_buttonStart = findViewById(R.id.button_start);
_editPassword = findViewById(R.id.edit_password);
_editPassword2 = findViewById(R.id.edit_password2);
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
@ -52,6 +54,13 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
}
_buttonStart.setOnClickListener {
val p1 = _editPassword.text.toString();
val p2 = _editPassword2.text.toString();
if(!(p1?.equals(p2) ?: false)) {
UIDialogs.toast(context, "Password fields do not match, confirm that you typed it correctly.");
return@setOnClickListener;
}
val pbytes = _editPassword.text.toString().toByteArray();
if(pbytes.size < 4 || pbytes.size > 32) {
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and smaller than 32 bytes", false);

View file

@ -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) {

View file

@ -6,6 +6,7 @@ import android.graphics.Color
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
@ -21,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
@ -29,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
@ -57,11 +58,21 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
_editComment = findViewById(R.id.edit_comment);
_textCharacterCount = findViewById(R.id.character_count);
_textCharacterCountMax = findViewById(R.id.character_count_max);
setCanceledOnTouchOutside(false)
setOnKeyListener { _, keyCode, event ->
if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
handleCloseAttempt()
true
} else {
false
}
}
_editComment.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) = Unit
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, c: Int) {
val count = s?.length ?: 0;
_textCharacterCount.text = count.toString();
if (count > PolycentricPlatformComment.MAX_COMMENT_SIZE) {
@ -79,10 +90,13 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
_buttonCancel.setOnClickListener {
clearFocus();
dismiss();
handleCloseAttempt()
};
setOnCancelListener {
handleCloseAttempt()
}
_buttonCreate.setOnClickListener {
clearFocus();
@ -134,6 +148,22 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
focus();
}
private fun handleCloseAttempt() {
if (_editComment.text.isEmpty()) {
clearFocus()
dismiss()
} else {
UIDialogs.showConfirmationDialog(
context,
context.resources.getString(R.string.not_empty_close),
action = {
clearFocus()
dismiss()
}
)
}
}
private fun focus() {
_editComment.requestFocus();
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE);

View file

@ -73,11 +73,11 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
};
_rememberedAdapter.onConnect.subscribe { _ ->
dismiss()
UIDialogs.showCastingDialog(context)
//UIDialogs.showCastingDialog(context)
}
_adapter.onConnect.subscribe { _ ->
dismiss()
UIDialogs.showCastingDialog(context)
//UIDialogs.showCastingDialog(context)
}
_recyclerRememberedDevices.adapter = _rememberedAdapter;
_recyclerRememberedDevices.layoutManager = LinearLayoutManager(context);

View file

@ -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();
};

View file

@ -100,6 +100,7 @@ class VideoDownload {
var requireVideoSource: Boolean = false;
var requireAudioSource: Boolean = false;
var requiredCheck: Boolean = false;
@Contextual
@Transient
@ -140,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;
@ -164,7 +171,7 @@ class VideoDownload {
onStateChanged.emit(newState);
}
constructor(video: IPlatformVideo, targetPixelCount: Long? = null, targetBitrate: Long? = null) {
constructor(video: IPlatformVideo, targetPixelCount: Long? = null, targetBitrate: Long? = null, optionalSources: Boolean = false) {
this.video = SerializedPlatformVideo.fromVideo(video);
this.videoSource = null;
this.audioSource = null;
@ -175,8 +182,9 @@ class VideoDownload {
this.requiresLiveVideoSource = false;
this.requiresLiveAudioSource = false;
this.targetVideoName = videoSource?.name;
this.requireVideoSource = targetPixelCount != null
this.requireVideoSource = targetPixelCount != null;
this.requireAudioSource = targetBitrate != null; //TODO: May not be a valid check.. can only be determined after live fetch?
this.requiredCheck = optionalSources;
}
constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) {
this.video = SerializedPlatformVideo.fromVideo(video);
@ -233,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");
@ -250,6 +260,30 @@ class VideoDownload {
if(original !is IPlatformVideoDetails)
throw IllegalStateException("Original content is not media?");
if(requiredCheck) {
if(original.video is VideoUnMuxedSourceDescriptor) {
if(requireVideoSource) {
if((original.video as VideoUnMuxedSourceDescriptor).audioSources.any() && !original.video.videoSources.any()) {
requireVideoSource = false;
targetPixelCount = null;
}
}
if(requireAudioSource) {
if(!(original.video as VideoUnMuxedSourceDescriptor).audioSources.any() && original.video.videoSources.any()) {
requireAudioSource = false;
targetBitrate = null;
}
}
}
else {
if(requireAudioSource) {
requireAudioSource = false;
targetBitrate = null;
}
}
requiredCheck = false;
}
if(original.video.hasAnySource() && !original.isDownloadable()) {
Logger.i(TAG, "Attempted to download unsupported video [${original.name}]:${original.url}");
throw DownloadException("Unsupported video for downloading", false);
@ -284,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;
@ -335,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)
@ -374,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) {
@ -663,7 +709,7 @@ class VideoDownload {
val url = foundTemplateUrl.replace("\$Number\$", indexCounter.toString());
val data = if(executor != null)
executor.executeRequest(url, mapOf());
executor.executeRequest("GET", url, null, mapOf());
else {
val resp = client.get(url, mutableMapOf());
if(!resp.isOk)
@ -1026,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)
@ -1056,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)
@ -1108,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 {
@ -1119,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 {

View file

@ -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)

View file

@ -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;

View file

@ -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}");
};
}

View file

@ -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;
}
}

View file

@ -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);
}
}
}
}
}

View file

@ -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;
}

View file

@ -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");
}
}

View file

@ -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
@ -133,6 +133,8 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment {
Logger.w(TAG, "Failed to parse claim=$c", e)
}
}
if(!map.containsKey("Harbor"))
map.set("Harbor", polycentricProfile.getHarborUrl());
if (map.isNotEmpty())
setLinks(map, if (polycentricProfile.systemState.username.isNotBlank()) polycentricProfile.systemState.username else _lastChannel?.name ?: "")

View file

@ -1,12 +1,13 @@
package com.futo.platformplayer.fragment.channel.tab
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
@ -15,7 +16,6 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.models.JSPager
import com.futo.platformplayer.api.media.structures.IAsyncPager
import com.futo.platformplayer.api.media.structures.IPager
@ -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,12 +38,14 @@ 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 _llmVideo: LinearLayoutManager? = null;
private var _glmVideo: GridLayoutManager? = null;
private var _loading = false;
private var _pager_parent: IPager<IPlatformContent>? = null;
private var _pager: IPager<IPlatformContent>? = null;
@ -72,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;
}
@ -118,7 +122,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
super.onScrolled(recyclerView, dx, dy);
val recyclerResults = _recyclerResults ?: return;
val llmVideo = _llmVideo ?: return;
val llmVideo = _glmVideo ?: return;
val visibleItemCount = recyclerResults.childCount;
val firstVisibleItem = llmVideo.findFirstVisibleItemPosition();
@ -163,9 +167,10 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
this.onLongPress.subscribe(this@ChannelContentsFragment.onLongPress::emit);
}
_llmVideo = LinearLayoutManager(view.context);
val numColumns = max((resources.configuration.screenWidthDp.toDouble() / resources.getInteger(R.integer.column_width_dp)).toInt(), 1)
_glmVideo = GridLayoutManager(view.context, numColumns);
_recyclerResults?.adapter = _adapterResults;
_recyclerResults?.layoutManager = _llmVideo;
_recyclerResults?.layoutManager = _glmVideo;
_recyclerResults?.addOnScrollListener(_scrollListener);
return view;
@ -181,6 +186,13 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
_nextPageHandler.cancel();
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
_glmVideo?.spanCount =
max((resources.configuration.screenWidthDp.toDouble() / resources.getInteger(R.integer.column_width_dp)).toInt(), 1)
}
/*
private fun setPager(pager: IPager<IPlatformContent>, cache: FeedFragment.ItemCache<IPlatformContent>? = null) {
if (_pager_parent != null && _pager_parent is IRefreshPager<*>) {
@ -358,6 +370,6 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
companion object {
val TAG = "VideoListFragment";
fun newInstance() = ChannelContentsFragment().apply { }
fun newInstance(subType: String? = null) = ChannelContentsFragment(subType).apply { }
}
}

View file

@ -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();

View file

@ -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 {

View file

@ -1,12 +1,13 @@
package com.futo.platformplayer.fragment.channel.tab
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
@ -36,10 +37,11 @@ import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.math.max
class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment {
private var _recyclerResults: RecyclerView? = null
private var _llmPlaylist: LinearLayoutManager? = null
private var _glmPlaylist: GridLayoutManager? = null
private var _loading = false
private var _pagerParent: IPager<IPlatformPlaylist>? = null
private var _pager: IPager<IPlatformPlaylist>? = null
@ -109,7 +111,7 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment {
super.onScrolled(recyclerView, dx, dy)
val recyclerResults = _recyclerResults ?: return
val llmPlaylist = _llmPlaylist ?: return
val llmPlaylist = _glmPlaylist ?: return
val visibleItemCount = recyclerResults.childCount
val firstVisibleItem = llmPlaylist.findFirstVisibleItemPosition()
@ -158,9 +160,10 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment {
this.onLongPress.subscribe(this@ChannelPlaylistsFragment.onLongPress::emit)
}
_llmPlaylist = LinearLayoutManager(view.context)
val numColumns = max((resources.configuration.screenWidthDp.toDouble() / resources.getInteger(R.integer.column_width_dp)).toInt(), 1)
_glmPlaylist = GridLayoutManager(view.context, numColumns)
_recyclerResults?.adapter = _adapterResults
_recyclerResults?.layoutManager = _llmPlaylist
_recyclerResults?.layoutManager = _glmPlaylist
_recyclerResults?.addOnScrollListener(_scrollListener)
return view
@ -176,6 +179,13 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment {
_nextPageHandler.cancel()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
_glmPlaylist?.spanCount =
max((resources.configuration.screenWidthDp.toDouble() / resources.getInteger(R.integer.column_width_dp)).toInt(), 1)
}
private fun setPager(
pager: IPager<IPlatformPlaylist>
) {

View file

@ -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)

View file

@ -7,6 +7,7 @@ import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -34,7 +35,7 @@ import kotlin.math.roundToInt
class MenuBottomBarFragment : MainActivityFragment() {
private var _view: MenuBottomBarView? = null;
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = MenuBottomBarView(this, inflater);
_view = view;
return view;
@ -56,7 +57,13 @@ class MenuBottomBarFragment : MainActivityFragment() {
return _view?.onBackPressed() ?: false;
}
@SuppressLint("ViewConstructor")
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
_view?.updateAllButtonVisibility()
}
@SuppressLint("ViewConstructor")
class MenuBottomBarView : LinearLayout {
private val _fragment: MenuBottomBarFragment;
private val _inflater: LayoutInflater;
@ -76,7 +83,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
private var _buttonsVisible = 0;
private var _subscriptionsVisible = true;
var currentButtonDefinitions: List<ButtonDefinition>? = null;
private var currentButtonDefinitions: List<ButtonDefinition>? = null;
constructor(fragment: MenuBottomBarFragment, inflater: LayoutInflater) : super(inflater.context) {
_fragment = fragment;
@ -132,7 +139,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
val staggerFactor = 3.0f
if (visible) {
moreOverlay.visibility = LinearLayout.VISIBLE
moreOverlay.visibility = VISIBLE
val animations = arrayListOf<Animator>()
animations.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 0.0f, 1.0f).setDuration(duration))
@ -161,7 +168,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
animatorSet.doOnEnd {
_moreVisibleAnimating = false
_moreVisible = false
moreOverlay.visibility = LinearLayout.INVISIBLE
moreOverlay.visibility = INVISIBLE
}
animatorSet.playTogether(animations)
animatorSet.start()
@ -178,7 +185,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
_layoutBottomBarButtons.removeAllViews();
_layoutBottomBarButtons.addView(Space(context).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)
})
for ((index, button) in buttons.withIndex()) {
@ -192,7 +199,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
_layoutBottomBarButtons.addView(menuButton)
if (index < buttonDefinitions.size - 1) {
_layoutBottomBarButtons.addView(Space(context).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)
})
}
@ -200,7 +207,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
}
_layoutBottomBarButtons.addView(Space(context).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)
})
}
@ -209,26 +216,30 @@ class MenuBottomBarFragment : MainActivityFragment() {
_moreButtons.clear();
_layoutMoreButtons.removeAllViews();
var insertedButtons = 0;
//Force buy to be on top for more buttons
val buyIndex = buttons.indexOfFirst { b -> b.id == 98 };
if (buyIndex != -1) {
val button = buttons[buyIndex]
buttons.removeAt(buyIndex)
buttons.add(0, button)
insertedButtons++;
}
//Force faq to be second
val faqIndex = buttons.indexOfFirst { b -> b.id == 97 };
if (faqIndex != -1) {
val button = buttons[faqIndex]
buttons.removeAt(faqIndex)
buttons.add(if (buttons.size == 1) 1 else 0, button)
buttons.add(if (insertedButtons == 1) 1 else 0, button)
insertedButtons++;
}
//Force privacy to be third
val privacyIndex = buttons.indexOfFirst { b -> b.id == 96 };
if (privacyIndex != -1) {
val button = buttons[privacyIndex]
buttons.removeAt(privacyIndex)
buttons.add(if (buttons.size == 2) 2 else 1, button)
buttons.add(if (insertedButtons == 2) 2 else (if(insertedButtons == 1) 1 else 0), button)
insertedButtons++;
}
for (data in buttons) {
@ -251,9 +262,20 @@ class MenuBottomBarFragment : MainActivityFragment() {
button.updateActive(_fragment);
}
override fun onConfigurationChanged(newConfig: Configuration?) {
super.onConfigurationChanged(newConfig)
updateAllButtonVisibility()
}
fun updateAllButtonVisibility() {
// if the more fly-out menu is open the we should close it
if(_moreVisible) {
setMoreVisible(false)
}
val defs = currentButtonDefinitions?.toMutableList() ?: return
val metrics = StateApp.instance.displayMetrics ?: resources.displayMetrics;
val metrics = resources.displayMetrics
_buttonsVisible = floor(metrics.widthPixels.toDouble() / 65.dp(resources).toDouble()).roundToInt();
if (_buttonsVisible >= defs.size) {
updateBottomMenuButtons(defs.toMutableList(), false);
@ -366,26 +388,13 @@ class MenuBottomBarFragment : MainActivityFragment() {
}),
ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate<SubscriptionsFeedFragment>() }),
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>() }),
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = true, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>() }),
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>() }),
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>() }),
ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>() }),
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>() }),
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>() }),
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>() }),
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>() }),
ButtonDefinition(11, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, {
it.navigate<BrowserFragment>(Settings.URL_FAQ)
}),
ButtonDefinition(12, R.drawable.ic_disabled_visible, R.drawable.ic_disabled_visible, R.string.privacy_mode, canToggle = true, { false }, {
UIDialogs.showDialog(it.requireContext(), R.drawable.ic_disabled_visible_purple, "Privacy Mode",
"All requests will be processed anonymously (unauthenticated), playback and history tracking will be disabled.\n\nTap the icon to disable.", null, 0,
UIDialogs.Action("Cancel", {
StateApp.instance.setPrivacyMode(false);
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Enable", {
StateApp.instance.setPrivacyMode(true);
}, UIDialogs.ActionStyle.PRIMARY));
}),
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
val c = it.context ?: return@ButtonDefinition;
Logger.i(TAG, "settings preventPictureInPicture()");
@ -395,7 +404,21 @@ class MenuBottomBarFragment : MainActivityFragment() {
if (c is Activity) {
c.overridePendingTransition(R.anim.slide_in_up, R.anim.slide_darken);
}
}),
ButtonDefinition(96, R.drawable.ic_disabled_visible, R.drawable.ic_disabled_visible, R.string.privacy_mode, canToggle = true, { false }, {
UIDialogs.showDialog(it.context ?: return@ButtonDefinition, R.drawable.ic_disabled_visible_purple, "Privacy Mode",
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
UIDialogs.Action("Cancel", {
StateApp.instance.setPrivacyMode(false);
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Enable", {
StateApp.instance.setPrivacyMode(true);
}, UIDialogs.ActionStyle.PRIMARY));
}),
ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, {
it.navigate<BrowserFragment>(Settings.URL_FAQ);
})
//96 is reserved for privacy button
//98 is reserved for buy button
//99 is reserved for more button
);

View file

@ -10,6 +10,7 @@ import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import com.futo.futopay.PaymentConfigurations
import com.futo.futopay.PaymentManager
import com.futo.futopay.formatMoney
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
@ -89,14 +90,13 @@ class BuyFragment : MainFragment() {
try {
val currencies = StatePayment.instance.getAvailableCurrencies("grayjay");
val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay");
val country = StatePayment.instance.getPaymentCountryFromIP()?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } };
val country = StatePayment.instance.getPaymentCountryFromIP(true)?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } };
val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id)) } };
if(currency != null && prices.containsKey(currency.id)) {
val price = prices[currency.id]!!;
val priceDecimal = (price.toDouble() / 100);
withContext(Dispatchers.Main) {
_buttonBuyText.text = currency.symbol + String.format("%.2f", priceDecimal) + context.getString(R.string.plus_tax);
_buttonBuyText.text = formatMoney(country.id, currency.id, price) + context.getString(R.string.plus_tax);
}
}
}

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