Compare commits

...

107 Commits

Author SHA1 Message Date
Koen J 715c60dc6e Fixed Chromecast position not updating on Grayjay side. Fixed Chromecast not reconnecting properly. Fixed AirPlay/Chromecast position not being reflected in history. 2025-06-04 12:13:18 +02:00
Koen J 916d052688 Updated submodules. 2025-06-04 11:04:00 +02:00
Koen J 993b812c3b Removed drag handles when searching in playlist. 2025-06-04 10:44:00 +02:00
Koen J 43887586b5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-30 18:39:45 +02:00
Koen J 03d53f21a3 Tab changes no longer fill up history. 2025-05-30 18:39:28 +02:00
Kelvin 23d7e8e5b6 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-30 17:20:28 +02:00
Kelvin cce117c585 Article header support, socket handling improvements 2025-05-30 17:20:11 +02:00
Koen J 303bd1b805 Fixed spacing issue in bottom bar when button sizes vary. Potential fix for crash that happens in ShapeableImageView. 2025-05-30 14:11:05 +02:00
koen-futo c7f4a40342 Merge pull request #2288 from quonverbat/typo-fix
Fixed typo
2025-05-30 13:50:56 +02:00
quonverbat 208c6c0776 Fixed typo 2025-05-30 00:50:22 +03:00
Kelvin 7d5c8347ce Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-29 18:37:29 +02:00
Kelvin bd70131252 Wrapper fix for unsupported enum parameters 2025-05-29 18:37:12 +02:00
Kelvin 43a373eceb Merge branch 'article-web-support' into 'master'
Article/web support

See merge request videostreaming/grayjay!106
2025-05-29 16:08:04 +00:00
Kelvin 5bb3466ffe Web support, additional article support 2025-05-29 18:02:55 +02:00
Kai 75e97ed008 Merge remote-tracking branch 'origin/master' 2025-05-29 10:24:51 -05:00
Kai ee28604c11 switch audio HLS to m4a
Changelog: changed
2025-05-29 10:24:23 -05:00
Kelvin a7d89e1bfb Missing files 2025-05-29 16:51:31 +02:00
Kelvin cbfd9ea559 Article support 2025-05-29 16:51:06 +02:00
Koen J dae50c3bc3 Implemented navigateWhenReady for url handling. 2025-05-29 16:07:14 +02:00
Koen J e651e59dc4 Theoretical crashfix for the case where activity does not successfully resume before handleUrl is invoked #2245. 2025-05-29 15:39:46 +02:00
Koen J 80d78761bf Made chromecast more robust. Improvements to IPv6 handling of casting devices. 2025-05-29 15:17:26 +02:00
Kelvin fb85aa4f32 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into article-web-support 2025-05-29 13:05:09 +02:00
Kelvin 9635c95efe WIP article 2025-05-29 13:05:00 +02:00
Koen J 033a237488 Changed getLocalUrl logic. 2025-05-29 13:03:51 +02:00
Koen J ec22c58822 Made a fix for ui mode changes causing app restarts. 2025-05-29 11:58:10 +02:00
Koen J 274942b5ba Hide search for any tab that isn't videos. 2025-05-29 11:55:21 +02:00
Koen J 94ab3da0e4 Added separate error status code for transport rejection. Added unhandled exception handler for relay loop. Added additional booleans to keep track of the server/relay connections being up/down. Added additional messaging to let the user know when something is wrong. 2025-05-29 11:51:59 +02:00
Kai DeLorenzo 5d44f0f2b6 Merge branch 'download-encrypted-hls' into 'master'
Encrypted HLS Download

See merge request videostreaming/grayjay!89
2025-05-29 04:20:54 +00:00
Kai f051e6b452 Merge branch 'master' into download-encrypted-hls
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/parsers/HLS.kt
2025-05-28 23:20:22 -05:00
Kai DeLorenzo 46a4284253 Merge branch 'hls-audio-fixes' into 'master'
HLS audio download

See merge request videostreaming/grayjay!86
2025-05-29 04:17:55 +00:00
Kai 0a708c6892 Merge branch 'master' into hls-audio-fixes
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
2025-05-28 23:12:50 -05:00
Kai DeLorenzo 0f96164dc3 Merge branch 'small-hls-fixes' into 'master'
Fix HLS downloading for nebula

See merge request videostreaming/grayjay!85
2025-05-29 04:10:06 +00:00
Kai 91c4917021 Merge branch 'master' into download-encrypted-hls
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt
2025-05-28 22:55:09 -05:00
Kai c32ebe016b fix extension
Changelog: changed
2025-05-28 17:33:35 -05:00
Kai ea26eefc2d Merge branch 'master' into hls-audio-fixes 2025-05-28 17:24:32 -05:00
Kai 418f4a6075 Merge branch 'master' into small-hls-fixes 2025-05-28 16:57:57 -05:00
Koen J 0ec921709a Fixed search visibility and channel loader when changing query. 2025-05-28 20:32:09 +02:00
Koen J e0811cfd93 Implemented new channel search. 2025-05-28 20:08:22 +02:00
Kai DeLorenzo f6b0778eb6 Merge branch 'ffmpeg-build-fix' into 'master'
add smart exception dependency for usage by local ffmpegkit dependency

See merge request videostreaming/grayjay!105
2025-05-27 19:06:50 +00:00
Kai 18aec34c0e add smart exception dependency for usage by local ffmpegkit dependency
Changelog: added
2025-05-27 14:05:51 -05:00
Kelvin bd185776e7 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into article-web-support 2025-05-27 15:49:27 +02:00
Kelvin fca5fe38bb WIP Article/WEb 2025-05-27 15:49:20 +02:00
koen-futo 1c2c7b376d Merge pull request #2152 from deenle/master
Added adaptive monochrome launcher icon
2025-05-27 11:47:33 +02:00
Koen J 670df86114 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-27 11:47:08 +02:00
Koen 55fb4d4562 Merge branch 'add-crunchyroll' into 'master'
Add Crunchyroll

See merge request videostreaming/grayjay!104
2025-05-26 12:59:15 +00:00
Koen J c703d018bd Fixed issue where loop video would not reset when opening a playlist, causing the video to loop without being able to disable it. 2025-05-26 14:22:49 +02:00
Stefan 425a27e130 Add Crunchyroll 2025-05-26 00:47:54 +01:00
Kai bd1b0e875b Merge branch 'master' into hls-audio-fixes
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
#	app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt
#	app/src/main/java/com/futo/platformplayer/parsers/HLS.kt
2025-05-22 15:04:15 -05:00
Kai 1509c11f64 check to see if an HLS playlist is a master playlist before parsing it
Changelog: changed
2025-05-22 15:00:34 -05:00
Kai 57c1097fbc Merge branch 'master' into small-hls-fixes
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt
2025-05-22 14:02:28 -05:00
Kai 1d1728b92b switch audio hls to be an mp4 file
Changelog: changed
2025-05-22 13:44:50 -05:00
Kai DeLorenzo 5f9f6dbde8 Merge branch 'linked-channel-shorts-fix' into 'master'
Polycentric Channel Shorts Tab Fix

See merge request videostreaming/grayjay!100
2025-05-22 16:32:27 +00:00
Kai cc3639180b Merge branch 'master' into linked-channel-shorts-fix
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt
2025-05-22 11:31:05 -05:00
Kai 8aa4de7522 make sure the plugin supports shorts content
Changelog: added
2025-05-22 11:28:59 -05:00
Koen J ed1f7e7c72 Button incognito is now visible but translated when minimized. 2025-05-22 15:57:12 +02:00
Koen J 1ecd1f5e04 Fix for throttled networks (airplane wifi) freezing app opening downloaded content. 2025-05-22 13:45:57 +02:00
Koen J 1aa9adc899 Build fix. 2025-05-22 11:41:16 +02:00
Koen J f8b2da93b9 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-22 09:36:16 +02:00
Koen J b794ff47ef Added dependency. 2025-05-22 09:34:32 +02:00
Kai DeLorenzo 6962a0547a Merge branch 'fix-get-replies' into 'master'
Fix getReplies in source.js

See merge request videostreaming/grayjay!102
2025-05-21 21:21:38 +00:00
Kai b906c1d36b add assignment for getReplies to PlatformComment
Changelog: added
2025-05-21 16:20:35 -05:00
Koen J af337b1874 Added missing checks for polycentric enabled. 2025-05-19 15:37:39 +02:00
Koen J 542235cca0 Keep live chat open when minimizing. #2227 2025-05-19 11:45:45 +02:00
Koen J f5673425b7 Updated default price. 2025-05-17 08:54:40 +02:00
Koen J 94965cf3ba Use snapshots for sending order to improve send efficiency. 2025-05-16 12:34:48 +02:00
Koen J 120ded5274 Prepared Sync logic to be separated from the rest of the logic. 2025-05-16 12:11:41 +02:00
Koen J 705eb6a3fa Migrated service to SyncService. 2025-05-15 19:57:31 +02:00
Koen J 1eb62b31d2 Added client sided ping loops to prevent inactive channels. 2025-05-13 10:31:30 +02:00
koen-futo b145187fa8 Merge pull request #1803 from ajp-dev/patch-1
fix link typo for Script Signing
2025-05-12 09:47:51 +02:00
koen-futo 4da1e44fd1 Merge pull request #1724 from AlexandrePicavet/feat/player-seek-offset
feat(player): Add a setting to adjust player seek duration
2025-05-12 09:43:22 +02:00
koen-futo 4e70279982 Merge pull request #2136 from buzzcola3/master
Fix "previous song" button playing always from unshuffled queue, even when shuffle is used
2025-05-12 09:36:52 +02:00
koen-futo 233c8ee26e Merge pull request #2155 from realchrisolin/patch-1
Update AndroidManifest.xml
2025-05-12 09:24:28 +02:00
Koen 875adb4d79 Merge branch 'fix-other-cookie-handling' into 'master'
Pass httponly cookies to future requests

See merge request videostreaming/grayjay!88
2025-05-12 07:01:41 +00:00
Koen 456514c4d4 Merge branch 'hls-rumble-fix' into 'master'
Rumble Download HLS Fix

See merge request videostreaming/grayjay!95
2025-05-12 06:46:55 +00:00
Koen J dac1918b95 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-12 08:43:12 +02:00
Koen J 1d7429ad86 Fixed issue where private mode would not re-appear after closing the video. 2025-05-12 08:43:02 +02:00
Kai DeLorenzo 5d0e6615ab Edit VideoDownload.kt 2025-05-09 17:36:27 +00:00
Kai DeLorenzo dc415df8c0 Edit VideoDownload.kt 2025-05-09 17:35:36 +00:00
Kai DeLorenzo 45ce251c4c Merge branch 'streamline-self-update' into 'master'
Silent Self Update

See merge request videostreaming/grayjay!81
2025-05-09 17:10:47 +00:00
Koen 2bc702112f Merge branch 'fix-brightness-restore' into 'master'
Brightness Restore On App Close

See merge request videostreaming/grayjay!99
2025-05-09 16:18:41 +00:00
Kai DeLorenzo abd73bf797 Merge branch 'theme-change-restart' into 'master'
Fix Restart On System Theme Change

See merge request videostreaming/grayjay!97
2025-05-09 15:17:52 +00:00
Kai e7e67b9572 organize imports
Changelog: changed
2025-05-09 10:17:15 -05:00
Kai 1a58b693c1 Merge branch 'master' into theme-change-restart
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt
2025-05-09 10:14:33 -05:00
Kai 50ecb909b4 Remove cookie
Changelog: changed
2025-05-09 10:08:24 -05:00
Koen 5e480be8db Merge branch 'fullscreen-glitch-fix' into 'master'
Fullscreen UI Glitch Fix

See merge request videostreaming/grayjay!101
2025-05-09 06:43:40 +00:00
Kai e3800426c9 Fix https://github.com/futo-org/grayjay-android/issues/1721
By setting video information section to visibility gone when going fullscreen

Changelog: changed
2025-04-28 10:13:26 -05:00
Kai 4acc867634 make channel shorts tab work correctly for linked channels
Changelog: changed
2025-04-23 11:38:09 -05:00
Kai 1a061268de restore brightness when leaving app while a video is full screen
Changelog: changed
2025-04-23 09:30:07 -05:00
Kai 5091a5485a grayjay only supports dark theme. set that on launch
Changelog: changed
2025-04-21 12:37:12 -05:00
Kai f8f1cababe increase analyze duration
Changelog: changed
2025-04-17 15:06:30 -05:00
Chris Olin ad46841397 Update AndroidManifest.xml
resolves https://github.com/futo-org/grayjay-android/issues/47
2025-04-16 11:17:39 -04:00
Tim B 20fb1e0fd0 Added monochrome launcher icon 2025-04-16 02:44:40 +02:00
buzzcola3 38b9fe3017 Fix prevQueueItem always returning the item from _queue, even when _queueShuffled is active 2025-04-13 20:36:15 +02:00
Kai DeLorenzo bdae35b1a8 Merge branch 'master' into 'small-hls-fixes'
# Conflicts:
#   app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
2025-03-24 02:12:27 +00:00
Kai 470b7bd2e5 add non iv version
Changelog: added
2025-02-20 21:27:44 -06:00
Kai 9014fb581d add support for downloading encrypted HLS streams
Changelog: changed
2025-02-20 16:07:35 -06:00
Kai 7ffa6b1bb3 revert params
Changelog: changed
2025-02-19 12:32:17 -06:00
Kai 3cd4b4503f fix other cookie handling
Changelog: changed
2025-02-19 11:59:59 -06:00
Kai d63fa521a1 Merge remote-tracking branch 'origin/hls-audio-fixes' into hls-audio-fixes
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt
2025-02-11 10:36:56 -06:00
Kai ca781dfe15 restore hard code HLS as mp4
Changelog: changed
2025-02-11 10:35:05 -06:00
Kai 4bc561ceab fix HLS audio download and download audio only
Changelog: changed
2025-02-11 10:34:39 -06:00
Kai 3d258180bd restore hard code HLS as mp4
Changelog: changed
2025-02-11 10:31:47 -06:00
Kai d5cab0910e fix HLS audio download and download audio only
Changelog: changed
2025-02-10 22:21:06 -06:00
Kai d4ccf232c1 fix HLS download odysee nebula peertube
Changelog: changed
2025-02-10 17:57:27 -06:00
Alexandre Picavet daf1d42a0f feat(player): Add a setting to adjust player seek duration
Create a seekOffset dropdown setting defaulting to 10 seconds.
Update the fastForwardTick method  of the GestureControlView to take the
seekOffset setting into account and update the view accordingly.
2025-02-04 22:00:08 +01:00
Kai a1d460385d prevent the user from needing to tap update on system dialog when self updating
Changelog: added
2025-01-20 15:38:26 -06:00
ajp-dev d2ed0c65ca fix link typo for Script Signing
typo causes HTTP 400 error when accessing link to Script Signing.md
2025-01-17 17:05:49 +08:00
100 changed files with 3895 additions and 1325 deletions
+2
View File
@@ -0,0 +1,2 @@
aar/* filter=lfs diff=lfs merge=lfs -text
app/aar/* filter=lfs diff=lfs merge=lfs -text
+6
View File
@@ -100,3 +100,9 @@
[submodule "app/src/unstable/assets/sources/curiositystream"]
path = app/src/unstable/assets/sources/curiositystream
url = ../plugins/curiositystream.git
[submodule "app/src/unstable/assets/sources/crunchyroll"]
path = app/src/unstable/assets/sources/crunchyroll
url = ../plugins/crunchyroll.git
[submodule "app/src/stable/assets/sources/crunchyroll"]
path = app/src/stable/assets/sources/crunchyroll
url = ../plugins/crunchyroll.git
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ea10d3c5562c9f449a4e89e9c3dfcf881ed79a952f3409bc005bcc62c2cf4b81
size 65512557
+2 -1
View File
@@ -197,7 +197,8 @@ 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:6.0-2.LTS'
implementation fileTree(dir: 'aar', include: ['*.aar'])
implementation 'com.arthenica:smart-exception-java:0.2.1'
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
implementation 'com.github.dhaval2404:imagepicker:2.1'
implementation 'com.google.zxing:core:3.4.1'
+3 -2
View File
@@ -15,6 +15,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<application
android:allowBackup="true"
@@ -55,7 +56,7 @@
<activity
android:name=".activities.MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar"
android:launchMode="singleInstance"
@@ -239,4 +240,4 @@
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
</application>
</manifest>
</manifest>
+40 -5
View File
@@ -32,7 +32,8 @@ let Type = {
Text: {
RAW: 0,
HTML: 1,
MARKUP: 2
MARKUP: 2,
CODE: 3
},
Chapter: {
NORMAL: 0,
@@ -291,15 +292,39 @@ class PlatformPostDetails extends PlatformPost {
}
}
class PlatformArticleDetails extends PlatformContent {
class PlatformWeb extends PlatformContent {
constructor(obj) {
super(obj, 7);
obj = obj ?? {};
this.plugin_type = "PlatformWeb";
}
}
class PlatformWebDetails extends PlatformWeb {
constructor(obj) {
super(obj, 7);
obj = obj ?? {};
this.plugin_type = "PlatformWebDetails";
this.html = obj.html;
}
}
class PlatformArticle extends PlatformContent {
constructor(obj) {
super(obj, 3);
obj = obj ?? {};
this.plugin_type = "PlatformArticle";
this.rating = obj.rating ?? new RatingLikes(-1);
this.summary = obj.summary ?? "";
this.thumbnails = obj.thumbnails ?? new Thumbnails([]);
}
}
class PlatformArticleDetails extends PlatformArticle {
constructor(obj) {
super(obj, 3);
obj = obj ?? {};
this.plugin_type = "PlatformArticleDetails";
this.rating = obj.rating ?? new RatingLikes(-1);
this.summary = obj.summary ?? "";
this.segments = obj.segments ?? [];
this.thumbnails = obj.thumbnails ?? new Thumbnails([]);
}
}
class ArticleSegment {
@@ -315,9 +340,17 @@ class ArticleTextSegment extends ArticleSegment {
}
}
class ArticleImagesSegment extends ArticleSegment {
constructor(images) {
constructor(images, caption) {
super(2);
this.images = images;
this.caption = caption;
}
}
class ArticleHeaderSegment extends ArticleSegment {
constructor(content, level) {
super(3);
this.level = level;
this.content = content;
}
}
class ArticleNestedSegment extends ArticleSegment {
@@ -595,6 +628,8 @@ class PlatformComment {
this.date = obj.date ?? 0;
this.replyCount = obj.replyCount ?? 0;
this.context = obj.context ?? {};
if(obj.getReplies)
this.getReplies = obj.getReplies;
}
}
@@ -217,6 +217,8 @@ private fun ByteArray.toInetAddress(): InetAddress {
}
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? {
ensureNotMainThread()
val timeout = 2000
@@ -499,6 +499,22 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22)
var deleteFromWatchLaterAuto: Boolean = true;
@FormField(R.string.seek_offset, FieldForm.DROPDOWN, R.string.seek_offset_description, 23)
@DropdownFieldOptionsId(R.array.seek_offset_duration)
var seekOffset: Int = 2;
fun getSeekOffset(): Long {
return when(seekOffset) {
0 -> 3_000L;
1 -> 5_000L;
2 -> 10_000L;
3 -> 20_000L;
4 -> 30_000L;
5 -> 60_000L;
else -> 10_000L;
}
}
}
@FormField(R.string.comments, "group", R.string.comments_description, 6)
@@ -1019,4 +1035,4 @@ class Settings : FragmentedStorageFileJson() {
}
}
//endregion
}
}
@@ -4,8 +4,14 @@ import android.app.NotificationManager
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.View
import android.view.ViewGroup
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
@@ -37,6 +43,9 @@ import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.parsers.HLS.MediaRendition
import com.futo.platformplayer.parsers.HLS.StreamInfo
import com.futo.platformplayer.parsers.HLS.VariantPlaylistReference
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StateHistory
@@ -63,6 +72,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
import androidx.core.net.toUri
class UISlideOverlays {
companion object {
@@ -299,6 +310,7 @@ class UISlideOverlays {
}
@OptIn(UnstableApi::class)
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
val items = arrayListOf<View>(LoaderView(container.context))
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
@@ -310,6 +322,8 @@ class UISlideOverlays {
val masterPlaylistContent = masterPlaylistResponse.body?.string()
?: throw Exception("Master playlist content is empty")
val resolvedPlaylistUrl = masterPlaylistResponse.url
val videoButtons = arrayListOf<SlideUpMenuItem>()
val audioButtons = arrayListOf<SlideUpMenuItem>()
//TODO: Implement subtitles
@@ -322,55 +336,103 @@ class UISlideOverlays {
val masterPlaylist: HLS.MasterPlaylist
try {
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl)
val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray())
val playlist = DefaultHlsPlaylistParserFactory().createPlaylistParser()
.parse(sourceUrl.toUri(), inputStream)
masterPlaylist.getAudioSources().forEach { it ->
if (playlist is HlsMediaPlaylist) {
if (source is IHLSManifestAudioSource) {
val variant = HLS.mediaRenditionToVariant(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null))!!
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
audioButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_music,
it.name,
listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
(prefix + it.codec).trim(),
tag = it,
call = {
selectedAudioVariant = it
slideUpMenuOverlay.selectOption(audioButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
},
invokeParent = false
))
}
/*masterPlaylist.getSubtitleSources().forEach { it ->
subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
selectedSubtitleVariant = it
slideUpMenuOverlay.selectOption(subtitleButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}, false))
}*/
masterPlaylist.getVideoSources().forEach {
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
videoButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
it.name,
"${it.width}x${it.height}",
(prefix + it.codec).trim(),
tag = it,
call = {
selectedVideoVariant = it
slideUpMenuOverlay.selectOption(videoButtons, it)
if (audioButtons.isEmpty()){
val estSize = VideoHelper.estimateSourceSize(variant);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
audioButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_music,
variant.name,
listOf(variant.language, variant.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
(prefix + variant.codec).trim(),
tag = variant,
call = {
selectedAudioVariant = variant
slideUpMenuOverlay.selectOption(audioButtons, variant)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}
},
invokeParent = false
))
},
invokeParent = false
))
} else {
val variant = HLS.variantReferenceToVariant(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null)))
val estSize = VideoHelper.estimateSourceSize(variant);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
videoButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
variant.name,
"${variant.width}x${variant.height}",
(prefix + variant.codec).trim(),
tag = variant,
call = {
selectedVideoVariant = variant
slideUpMenuOverlay.selectOption(videoButtons, variant)
if (audioButtons.isEmpty()){
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}
},
invokeParent = false
))
}
} else if (playlist is HlsMultivariantPlaylist) {
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, resolvedPlaylistUrl)
masterPlaylist.getAudioSources().forEach { it ->
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
audioButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_music,
it.name,
listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
(prefix + it.codec).trim(),
tag = it,
call = {
selectedAudioVariant = it
slideUpMenuOverlay.selectOption(audioButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
},
invokeParent = false
))
}
/*masterPlaylist.getSubtitleSources().forEach { it ->
subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
selectedSubtitleVariant = it
slideUpMenuOverlay.selectOption(subtitleButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}, false))
}*/
masterPlaylist.getVideoSources().forEach {
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
videoButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
it.name,
"${it.width}x${it.height}",
(prefix + it.codec).trim(),
tag = it,
call = {
selectedVideoVariant = it
slideUpMenuOverlay.selectOption(videoButtons, it)
if (audioButtons.isEmpty()){
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}
},
invokeParent = false
))
}
}
val newItems = arrayListOf<View>()
@@ -398,11 +460,11 @@ class UISlideOverlays {
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
withContext(Dispatchers.Main) {
if (source is IHLSManifestSource) {
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, sourceUrl), null, null)
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null)
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, false, sourceUrl), null)
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null)
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
slideUpMenuOverlay.hide()
} else {
@@ -984,26 +1046,30 @@ class UISlideOverlays {
+ actions).filterNotNull()
));
items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto",
SlideUpMenuItem(container.context,
SlideUpMenuGroup(
container.context, container.context.getString(R.string.add_to), "addto",
SlideUpMenuItem(
container.context,
R.drawable.ic_queue_add,
container.context.getString(R.string.add_to_queue),
"${queue.size} " + container.context.getString(R.string.videos),
tag = "queue",
call = { StatePlayer.instance.addToQueue(video); }),
SlideUpMenuItem(container.context,
SlideUpMenuItem(
container.context,
R.drawable.ic_watchlist_add,
"${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), true); }),
SlideUpMenuItem(container.context,
SlideUpMenuItem(
container.context,
R.drawable.ic_history,
container.context.getString(R.string.add_to_history),
"Mark as watched",
tag = "history",
call = { StateHistory.instance.markAsWatched(video); }),
));
));
val playlistItems = arrayListOf<SlideUpMenuItem>();
playlistItems.add(SlideUpMenuItem(
@@ -1067,14 +1133,17 @@ class UISlideOverlays {
val queue = StatePlayer.instance.getQueue();
val watchLater = StatePlaylists.instance.getWatchLater();
items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other",
SlideUpMenuItem(container.context,
SlideUpMenuGroup(
container.context, container.context.getString(R.string.other), "other",
SlideUpMenuItem(
container.context,
R.drawable.ic_queue_add,
container.context.getString(R.string.queue),
"${queue.size} " + container.context.getString(R.string.videos),
tag = "queue",
call = { StatePlayer.instance.addToQueue(video); }),
SlideUpMenuItem(container.context,
SlideUpMenuItem(
container.context,
R.drawable.ic_watchlist_add,
StatePlayer.TYPE_WATCHLATER,
"${watchLater.size} " + container.context.getString(R.string.videos),
@@ -1083,7 +1152,7 @@ class UISlideOverlays {
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true))
UIDialogs.appToast("Added to watch later", false);
}),
)
)
);
val playlistItems = arrayListOf<SlideUpMenuItem>();
@@ -1121,8 +1190,8 @@ class UISlideOverlays {
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() };
}
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>, isChannelSearch: Boolean = false): SlideUpMenuFilters {
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues, isChannelSearch);
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>): SlideUpMenuFilters {
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues);
overlay.show();
return overlay;
}
@@ -31,6 +31,12 @@ import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
import java.net.InterfaceAddress
import java.net.NetworkInterface
import java.net.SocketException
import java.nio.ByteBuffer
import java.security.SecureRandom
import java.time.OffsetDateTime
@@ -331,4 +337,98 @@ fun ByteArray.fromGzip(): ByteArray {
}
}
return outputStream.toByteArray()
}
}
fun findPreferredAddress(): InetAddress? {
val candidates = NetworkInterface.getNetworkInterfaces()
.toList()
.asSequence()
.filter(::isUsableInterface)
.flatMap { nif ->
nif.interfaceAddresses
.asSequence()
.mapNotNull { ia ->
ia.address.takeIf(::isUsableAddress)?.let { addr ->
nif to ia
}
}
}
.toList()
return candidates
.minWithOrNull(
compareBy<Pair<NetworkInterface, InterfaceAddress>>(
{ addressScore(it.second.address) },
{ interfaceScore(it.first) },
{ -it.second.networkPrefixLength.toInt() },
{ -it.first.mtu }
)
)?.second?.address
}
private fun isUsableInterface(nif: NetworkInterface): Boolean {
val name = nif.name.lowercase()
return try {
// must be up, not loopback/virtual/PtP, have a MAC, not Docker/tun/etc.
nif.isUp
&& !nif.isLoopback
&& !nif.isPointToPoint
&& !nif.isVirtual
&& !name.startsWith("docker")
&& !name.startsWith("veth")
&& !name.startsWith("br-")
&& !name.startsWith("virbr")
&& !name.startsWith("vmnet")
&& !name.startsWith("tun")
&& !name.startsWith("tap")
} catch (e: SocketException) {
false
}
}
private fun isUsableAddress(addr: InetAddress): Boolean {
return when {
addr.isAnyLocalAddress -> false // 0.0.0.0 / ::
addr.isLoopbackAddress -> false
addr.isLinkLocalAddress -> false // 169.254.x.x or fe80::/10
addr.isMulticastAddress -> false
else -> true
}
}
private fun interfaceScore(nif: NetworkInterface): Int {
val name = nif.name.lowercase()
return when {
name.matches(Regex("^(eth|enp|eno|ens|em)\\d+")) -> 0
name.startsWith("eth") || name.contains("ethernet") -> 0
name.matches(Regex("^(wlan|wlp)\\d+")) -> 1
name.contains("wi-fi") || name.contains("wifi") -> 1
else -> 2
}
}
private fun addressScore(addr: InetAddress): Int {
return when (addr) {
is Inet4Address -> {
val octets = addr.address.map { it.toInt() and 0xFF }
when {
octets[0] == 10 -> 0 // 10/8
octets[0] == 192 && octets[1] == 168 -> 0 // 192.168/16
octets[0] == 172 && octets[1] in 16..31 -> 0 // 172.1631/12
else -> 1 // public IPv4
}
}
is Inet6Address -> {
// ULA (fc00::/7) vs global vs others
val b0 = addr.address[0].toInt() and 0xFF
when {
(b0 and 0xFE) == 0xFC -> 2 // ULA
(b0 and 0xE0) == 0x20 -> 3 // global
else -> 4
}
}
else -> Int.MAX_VALUE
}
}
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
@@ -2,14 +2,14 @@ package com.futo.platformplayer.activities
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.ComponentName
import android.app.UiModeManager
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.os.Build
import android.os.Bundle
import android.os.StrictMode
import android.os.StrictMode.VmPolicy
@@ -22,6 +22,7 @@ import android.widget.ImageView
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.core.app.ActivityCompat
@@ -31,6 +32,8 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.whenStateAtLeast
import androidx.lifecycle.withStateAtLeast
import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
@@ -39,7 +42,9 @@ 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.dp
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.fragment.mainactivity.main.ArticleDetailFragment
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
import com.futo.platformplayer.fragment.mainactivity.main.BuyFragment
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
@@ -66,7 +71,9 @@ import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragm
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
import com.futo.platformplayer.fragment.mainactivity.main.TutorialFragment
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment.State
import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment
import com.futo.platformplayer.fragment.mainactivity.main.WebDetailFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
@@ -75,7 +82,6 @@ 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
@@ -147,6 +153,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//Frags Main
lateinit var _fragMainHome: HomeFragment;
lateinit var _fragPostDetail: PostDetailFragment;
lateinit var _fragArticleDetail: ArticleDetailFragment;
lateinit var _fragWebDetail: WebDetailFragment;
lateinit var _fragMainVideoSearchResults: ContentSearchResultsFragment;
lateinit var _fragMainCreatorSearchResults: CreatorSearchResultsFragment;
lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment;
@@ -189,7 +197,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private var _privateModeEnabled = false
private var _pictureInPictureEnabled = false
private var _isFullscreen = false
private var _isMinimized = false
private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
@@ -201,7 +208,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
try {
runBlocking {
lifecycleScope.launch {
handleUrlAll(content)
}
} catch (e: Throwable) {
@@ -267,6 +274,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateApp.instance.mainAppStarting(this);
super.onCreate(savedInstanceState);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val uiMode = getSystemService(UiModeManager::class.java)
uiMode.setApplicationNightMode(UiModeManager.MODE_NIGHT_YES)
}
setContentView(R.layout.activity_main);
setNavigationBarColorAndIcons();
if (Settings.instance.playback.allowVideoToGoUnderCutout)
@@ -274,7 +285,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
runBlocking {
StatePlatform.instance.updateAvailableClients(this@MainActivity);
try {
StatePlatform.instance.updateAvailableClients(this@MainActivity);
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception in updateAvailableClients", e)
}
}
//Preload common files to memory
@@ -318,6 +333,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragMainPlaylist = PlaylistFragment.newInstance();
_fragMainRemotePlaylist = RemotePlaylistFragment.newInstance();
_fragPostDetail = PostDetailFragment.newInstance();
_fragArticleDetail = ArticleDetailFragment.newInstance();
_fragWebDetail = WebDetailFragment.newInstance();
_fragWatchlist = WatchLaterFragment.newInstance();
_fragHistory = HistoryFragment.newInstance();
_fragSourceDetail = SourceDetailFragment.newInstance();
@@ -359,12 +376,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragMainSubscriptionsFeed.setPreviewsEnabled(true);
_fragContainerVideoDetail.visibility = View.INVISIBLE;
updateSegmentPaddings();
updatePrivateModeVisibility()
};
_buttonIncognito = findViewById(R.id.incognito_button);
_buttonIncognito.elevation = -99f;
_buttonIncognito.alpha = 0f;
updatePrivateModeVisibility()
StateApp.instance.privateModeChanged.subscribe {
//Messing with visibility causes some issues with layout ordering?
_privateModeEnabled = it
@@ -392,12 +409,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
_fragVideoDetail.onMinimize.subscribe {
_isMinimized = true
updatePrivateModeVisibility()
}
_fragVideoDetail.onMaximized.subscribe {
_isMinimized = false
updatePrivateModeVisibility()
}
@@ -446,6 +461,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragMainPlaylist.topBar = _fragTopBarNavigation;
_fragMainRemotePlaylist.topBar = _fragTopBarNavigation;
_fragPostDetail.topBar = _fragTopBarNavigation;
_fragArticleDetail.topBar = _fragTopBarNavigation;
_fragWebDetail.topBar = _fragTopBarNavigation;
_fragWatchlist.topBar = _fragTopBarNavigation;
_fragHistory.topBar = _fragTopBarNavigation;
_fragSourceDetail.topBar = _fragTopBarNavigation;
@@ -640,13 +657,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
fun updatePrivateModeVisibility() {
if (_privateModeEnabled && !_pictureInPictureEnabled && !_isFullscreen && !_isMinimized) {
@OptIn(UnstableApi::class)
private fun updatePrivateModeVisibility() {
if (_privateModeEnabled && (_fragVideoDetail.state == State.CLOSED || !_pictureInPictureEnabled && !_isFullscreen)) {
_buttonIncognito.elevation = 99f;
_buttonIncognito.alpha = 1f;
_buttonIncognito.layoutParams = _buttonIncognito.layoutParams.apply {
}
_buttonIncognito.translationY = if (_fragVideoDetail.state == State.MINIMIZED) -60.dp(resources).toFloat() else 0f
} else {
_buttonIncognito.elevation = -99f;
_buttonIncognito.alpha = 0f;
@@ -704,7 +720,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
"VIDEO" -> {
val url = intent.getStringExtra("VIDEO");
navigate(_fragVideoDetail, url);
navigateWhenReady(_fragVideoDetail, url);
}
"IMPORT_OPTIONS" -> {
@@ -722,11 +738,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
"Sources" -> {
runBlocking {
StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed..
navigate(_fragMainSources);
navigateWhenReady(_fragMainSources);
}
};
"BROWSE_PLUGINS" -> {
navigate(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
navigateWhenReady(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
Pair("grayjay") { req ->
StateApp.instance.contextOrNull?.let {
if (it is MainActivity) {
@@ -744,8 +760,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
try {
if (targetData != null) {
runBlocking {
handleUrlAll(targetData)
lifecycleScope.launch(Dispatchers.Main) {
try {
handleUrlAll(targetData)
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception in handleUrlAll", e)
}
}
}
} catch (ex: Throwable) {
@@ -773,10 +793,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
startActivity(intent);
} else if (url.startsWith("grayjay://video/")) {
val videoUrl = url.substring("grayjay://video/".length);
navigate(_fragVideoDetail, videoUrl);
navigateWhenReady(_fragVideoDetail, videoUrl);
} else if (url.startsWith("grayjay://channel/")) {
val channelUrl = url.substring("grayjay://channel/".length);
navigate(_fragMainChannel, channelUrl);
navigateWhenReady(_fragMainChannel, channelUrl);
}
}
@@ -842,29 +862,29 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return withContext(Dispatchers.IO) {
Logger.i(TAG, "handleUrl(url=$url) on IO");
if (StatePlatform.instance.hasEnabledVideoClient(url)) {
if (StatePlatform.instance.hasEnabledContentClient(url)) {
Logger.i(TAG, "handleUrl(url=$url) found video client");
lifecycleScope.launch(Dispatchers.Main) {
withContext(Dispatchers.Main) {
if (position > 0)
navigate(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true));
navigateWhenReady(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true));
else
navigate(_fragVideoDetail, url);
navigateWhenReady(_fragVideoDetail, url);
_fragVideoDetail.maximizeVideoDetail(true);
}
return@withContext true;
} else if (StatePlatform.instance.hasEnabledChannelClient(url)) {
Logger.i(TAG, "handleUrl(url=$url) found channel client");
lifecycleScope.launch(Dispatchers.Main) {
navigate(_fragMainChannel, url);
withContext(Dispatchers.Main) {
navigateWhenReady(_fragMainChannel, url);
delay(100);
_fragVideoDetail.minimizeVideoDetail();
};
return@withContext true;
} else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) {
Logger.i(TAG, "handleUrl(url=$url) found playlist client");
lifecycleScope.launch(Dispatchers.Main) {
navigate(_fragMainRemotePlaylist, url);
withContext(Dispatchers.Main) {
navigateWhenReady(_fragMainRemotePlaylist, url);
delay(100);
_fragVideoDetail.minimizeVideoDetail();
};
@@ -1091,6 +1111,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return fragCurrent is T;
}
fun navigateWhenReady(segment: MainFragment, parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
navigate(segment, parameter, withHistory, isBack)
} else {
lifecycleScope.launch {
lifecycle.withStateAtLeast(Lifecycle.State.RESUMED) {
navigate(segment, parameter, withHistory, isBack)
}
}
}
}
/**
* Navigate takes a MainFragment, and makes them the current main visible view
* A parameter can be provided which becomes available in the onShow of said fragment
@@ -1215,6 +1247,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
PlaylistFragment::class -> _fragMainPlaylist as T;
RemotePlaylistFragment::class -> _fragMainRemotePlaylist as T;
PostDetailFragment::class -> _fragPostDetail as T;
ArticleDetailFragment::class -> _fragArticleDetail as T;
WebDetailFragment::class -> _fragWebDetail as T;
WatchLaterFragment::class -> _fragWatchlist as T;
HistoryFragment::class -> _fragHistory as T;
SourceDetailFragment::class -> _fragSourceDetail as T;
@@ -9,6 +9,7 @@ import android.widget.LinearLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
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
@@ -100,12 +101,18 @@ class SyncHomeActivity : AppCompatActivity() {
}
}
StateSync.instance.confirmStarted(this, {
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity)
}, {
StateSync.instance.confirmStarted(this, onStarted = {
if (StateSync.instance.syncService?.serverSocketFailedToStart == true) {
UIDialogs.toast(this, "Server socket failed to start, is the port in use?", true)
}
if (StateSync.instance.syncService?.relayConnected == false) {
UIDialogs.toast(this, "Not connected to relay, remote connections will work.", false)
}
if (StateSync.instance.syncService?.serverSocketStarted == false) {
UIDialogs.toast(this, "Listener not started, local connections will not work.", false)
}
}, onNotStarted = {
finish()
}, {
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity)
})
}
@@ -110,7 +110,7 @@ class SyncPairActivity : AppCompatActivity() {
lifecycleScope.launch(Dispatchers.IO) {
try {
StateSync.instance.connect(deviceInfo) { complete, message ->
StateSync.instance.syncService?.connect(deviceInfo) { complete, message ->
lifecycleScope.launch(Dispatchers.Main) {
if (complete != null) {
if (complete) {
@@ -67,11 +67,18 @@ class SyncShowPairingCodeActivity : AppCompatActivity() {
}
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)
val publicKey = StateSync.instance.syncService?.publicKey
val pairingCode = StateSync.instance.syncService?.pairingCode
if (publicKey == null || pairingCode == null) {
setCode("Public key or pairing code was not known, is sync enabled?")
} else {
val selfDeviceInfo = SyncDeviceInfo(publicKey, ips.toTypedArray(), StateSync.PORT, 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?) {
@@ -90,6 +90,7 @@ open class ManagedHttpClient {
}
fun tryHead(url: String): Map<String, String>? {
ensureNotMainThread()
try {
val result = head(url);
if(result.isOk)
@@ -104,7 +105,7 @@ open class ManagedHttpClient {
}
fun socket(url: String, headers: MutableMap<String, String> = HashMap(), listener: SocketListener): Socket {
ensureNotMainThread()
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
.url(url);
if(user_agent.isNotEmpty() && !headers.any { it.key.lowercase() == "user-agent" })
@@ -300,6 +301,7 @@ open class ManagedHttpClient {
}
fun send(msg: String) {
ensureNotMainThread()
socket.send(msg);
}
@@ -14,14 +14,16 @@ class PlatformClientPool {
private var _poolCounter = 0;
private val _poolName: String?;
private val _privatePool: Boolean;
private val _isolatedInitialization: Boolean
var isDead: Boolean = false
private set;
val onDead = Event2<JSClient, PlatformClientPool>();
constructor(parentClient: IPlatformClient, name: String? = null, privatePool: Boolean = false) {
constructor(parentClient: IPlatformClient, name: String? = null, privatePool: Boolean = false, isolatedInitialization: Boolean = false) {
_poolName = name;
_privatePool = privatePool;
_isolatedInitialization = isolatedInitialization
if(parentClient !is JSClient)
throw IllegalArgumentException("Pooling only supported for JSClients right now");
Logger.i(TAG, "Pool for ${parentClient.name} was started");
@@ -53,7 +55,7 @@ class PlatformClientPool {
reserved = _pool.keys.find { !it.isBusy };
if(reserved == null && _pool.size < capacity) {
Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})");
reserved = _parent.getCopy(_privatePool);
reserved = _parent.getCopy(_privatePool, _isolatedInitialization);
reserved?.onCaptchaException?.subscribe { client, ex ->
StateApp.instance.handleCaptchaException(client, ex);
@@ -7,13 +7,15 @@ class PlatformMultiClientPool {
private var _isFake = false;
private var _privatePool = false;
private val _isolatedInitialization: Boolean
constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false) {
constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false, isolatedInitialization: Boolean = false) {
_name = name;
_maxCap = if(maxCap > 0)
maxCap
else 99;
_privatePool = isPrivatePool;
_isolatedInitialization = isolatedInitialization
}
fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient {
@@ -21,7 +23,7 @@ class PlatformMultiClientPool {
return parentClient;
val pool = synchronized(_clientPools) {
if(!_clientPools.containsKey(parentClient))
_clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool).apply {
_clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool, _isolatedInitialization).apply {
this.onDead.subscribe { _, pool ->
synchronized(_clientPools) {
if(_clientPools[parentClient] == pool)
@@ -0,0 +1,9 @@
package com.futo.platformplayer.api.media.models.article
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
interface IPlatformArticle: IPlatformContent {
val summary: String?;
val thumbnails: Thumbnails?;
}
@@ -0,0 +1,12 @@
package com.futo.platformplayer.api.media.models.article
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.platforms.js.models.IJSArticleSegment
interface IPlatformArticleDetails: IPlatformContent, IPlatformArticle, IPlatformContentDetails {
val segments: List<IJSArticleSegment>;
val rating : IRating;
}
@@ -8,6 +8,7 @@ enum class ContentType(val value: Int) {
POST(2),
ARTICLE(3),
PLAYLIST(4),
WEB(7),
URL(9),
@@ -5,7 +5,8 @@ import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException
enum class TextType(val value: Int) {
RAW(0),
HTML(1),
MARKUP(2);
MARKUP(2),
CODE(3);
companion object {
fun fromInt(value: Int): TextType
@@ -54,8 +54,11 @@ class DevJSClient : JSClient {
return DevJSClient(context, config, _devScript, _auth, _captcha, devID, descriptor.settings);
}
override fun getCopy(privateCopy: Boolean): JSClient {
return DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, saveState(), devID);
override fun getCopy(privateCopy: Boolean, noSaveState: Boolean): JSClient {
val client = DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, if (noSaveState) null else saveState(), devID);
if (noSaveState)
client.initialize()
return client
}
override fun initialize() {
@@ -195,8 +195,11 @@ open class JSClient : IPlatformClient {
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
}
open fun getCopy(withoutCredentials: Boolean = false): JSClient {
return JSClient(_context, descriptor, saveState(), _script, withoutCredentials);
open fun getCopy(withoutCredentials: Boolean = false, noSaveState: Boolean = false): JSClient {
val client = JSClient(_context, descriptor, if (noSaveState) null else saveState(), _script, withoutCredentials);
if (noSaveState)
client.initialize()
return client
}
fun getUnderlyingPlugin(): V8Plugin {
@@ -211,6 +214,8 @@ open class JSClient : IPlatformClient {
}
override fun initialize() {
if (_initialized) return
Logger.i(TAG, "Plugin [${config.name}] initializing");
plugin.start();
plugin.execute("plugin.config = ${Json.encodeToString(config)}");
@@ -127,7 +127,7 @@ class JSHttpClient : ManagedHttpClient {
}
if(doApplyCookies) {
if (_currentCookieMap.isNotEmpty()) {
if (_currentCookieMap.isNotEmpty() || _otherCookieMap.isNotEmpty()) {
val cookiesToApply = hashMapOf<String, String>();
synchronized(_currentCookieMap) {
for(cookie in _currentCookieMap
@@ -135,6 +135,12 @@ class JSHttpClient : ManagedHttpClient {
.flatMap { it.value.toList() })
cookiesToApply[cookie.first] = cookie.second;
};
synchronized(_otherCookieMap) {
for(cookie in _otherCookieMap
.filter { domain.matchesDomain(it.key) }
.flatMap { it.value.toList() })
cookiesToApply[cookie.first] = cookie.second;
}
if(cookiesToApply.size > 0) {
val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; ");
@@ -27,7 +27,9 @@ interface IJSContent: IPlatformContent {
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
ContentType.PLAYLIST -> JSPlaylist(config, obj);
ContentType.LOCKED -> JSLockedContent(config, obj);
ContentType.CHANNEL -> JSChannelContent(config, obj)
ContentType.CHANNEL -> JSChannelContent(config, obj);
ContentType.ARTICLE -> JSArticle(config, obj);
ContentType.WEB -> JSWeb(config, obj);
else -> throw NotImplementedError("Unknown content type ${type}");
}
}
@@ -17,6 +17,7 @@ interface IJSContentDetails: IPlatformContent {
ContentType.MEDIA -> JSVideoDetails(plugin, obj);
ContentType.POST -> JSPostDetails(plugin.config, obj);
ContentType.ARTICLE -> JSArticleDetails(plugin, obj);
ContentType.WEB -> JSWebDetails(plugin, obj);
else -> throw NotImplementedError("Unknown content type ${type}");
}
}
@@ -0,0 +1,39 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
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.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.post.TextType
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.states.StateDeveloper
open class JSArticle : JSContent, IPlatformArticle, IPluginSourced {
final override val contentType: ContentType get() = ContentType.ARTICLE;
override val summary: String;
override val thumbnails: Thumbnails?;
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformArticle";
summary = _content.getOrDefault(config, "summary", contextName, "") ?: "";
thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName));
}
}
@@ -4,6 +4,8 @@ import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails
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
@@ -21,20 +23,20 @@ import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.states.StateDeveloper
open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails {
open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
final override val contentType: ContentType get() = ContentType.ARTICLE;
private val _hasGetComments: Boolean;
private val _hasGetContentRecommendations: Boolean;
val rating: IRating;
override val rating: IRating;
val summary: String;
val thumbnails: Thumbnails?;
val segments: List<IJSArticleSegment>;
override val summary: String;
override val thumbnails: Thumbnails?;
override val segments: List<IJSArticleSegment>;
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
val contextName = "PlatformPost";
val contextName = "PlatformArticle";
rating = obj.getOrDefault<V8ValueObject>(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0);
summary = _content.getOrThrow(client.config, "summary", contextName);
@@ -99,6 +101,7 @@ open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails
return when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
SegmentType.TEXT -> JSTextSegment(client, obj);
SegmentType.IMAGES -> JSImagesSegment(client, obj);
SegmentType.HEADER -> JSHeaderSegment(client, obj);
SegmentType.NESTED -> JSNestedSegment(client, obj);
else -> null;
}
@@ -110,6 +113,7 @@ enum class SegmentType(val value: Int) {
UNKNOWN(0),
TEXT(1),
IMAGES(2),
HEADER(3),
NESTED(9);
@@ -150,6 +154,17 @@ class JSImagesSegment: IJSArticleSegment {
caption = obj.getOrDefault(client.config, "caption", contextName, "") ?: "";
}
}
class JSHeaderSegment: IJSArticleSegment {
override val type = SegmentType.HEADER;
val content: String;
val level: Int;
constructor(client: JSClient, obj: V8ValueObject) {
val contextName = "JSHeaderSegment";
content = obj.getOrDefault(client.config, "content", contextName, "") ?: "";
level = obj.getOrDefault(client.config, "level", contextName, 1) ?: 1;
}
}
class JSNestedSegment: IJSArticleSegment {
override val type = SegmentType.NESTED;
val nested: IPlatformContent;
@@ -0,0 +1,31 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
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.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.post.TextType
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.states.StateDeveloper
open class JSWeb : JSContent, IPluginSourced {
final override val contentType: ContentType get() = ContentType.WEB;
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformWeb";
}
}
@@ -0,0 +1,41 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
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.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.post.TextType
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.states.StateDeveloper
open class JSWebDetails : JSContent, IPluginSourced, IPlatformContentDetails {
final override val contentType: ContentType get() = ContentType.WEB;
val html: String?;
//TODO: Options?
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
val contextName = "PlatformWeb";
html = obj.getOrDefault(client.config, "html", contextName, null);
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;
override fun getPlaybackTracker(): IPlaybackTracker? = null;
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? = null;
}
@@ -108,7 +108,7 @@ abstract class CastingDevice {
val expectedCurrentTime: Double
get() {
val diff = (System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0;
val diff = if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff;
};
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
@@ -10,7 +10,9 @@ import com.futo.platformplayer.toHexString
import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.json.JSONObject
@@ -56,6 +58,10 @@ class ChromecastCastingDevice : CastingDevice {
private var _mediaSessionId: Int? = null;
private var _thread: Thread? = null;
private var _pingThread: Thread? = null;
private var _launchRetries = 0
private val MAX_LAUNCH_RETRIES = 3
private var _lastLaunchTime_ms = 0L
private var _retryJob: Job? = null
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
@@ -229,6 +235,7 @@ class ChromecastCastingDevice : CastingDevice {
launchObject.put("appId", "CC1AD845");
launchObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
_lastLaunchTime_ms = System.currentTimeMillis()
}
private fun getStatus() {
@@ -268,6 +275,7 @@ class ChromecastCastingDevice : CastingDevice {
_contentType = null;
_streamType = null;
_sessionId = null;
_launchRetries = 0
_transportId = null;
}
@@ -282,6 +290,7 @@ class ChromecastCastingDevice : CastingDevice {
_started = true;
_sessionId = null;
_launchRetries = 0
_mediaSessionId = null;
Logger.i(TAG, "Starting...");
@@ -335,6 +344,10 @@ class ChromecastCastingDevice : CastingDevice {
//Connection loop
while (_scopeIO?.isActive == true) {
_sessionId = null;
_launchRetries = 0
_mediaSessionId = null;
Logger.i(TAG, "Connecting to Chromecast.");
connectionState = CastConnectionState.CONNECTING;
@@ -393,7 +406,7 @@ class ChromecastCastingDevice : CastingDevice {
try {
val inputStream = _inputStream ?: break;
synchronized(_inputStreamLock)
val message = synchronized(_inputStreamLock)
{
Log.d(TAG, "Receiving next packet...");
val b1 = inputStream.readUnsignedByte();
@@ -405,7 +418,7 @@ class ChromecastCastingDevice : CastingDevice {
if (size > buffer.size) {
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
inputStream.skip(size.toLong());
return@synchronized
return@synchronized null
}
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
@@ -414,15 +427,19 @@ class ChromecastCastingDevice : CastingDevice {
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
val message = ChromeCast.CastMessage.parseFrom(messageBytes);
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
Logger.i(TAG, "Received message: $message");
val msg = ChromeCast.CastMessage.parseFrom(messageBytes);
if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
Logger.i(TAG, "Received message: $msg");
}
return@synchronized msg
}
if (message != null) {
try {
handleMessage(message);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to handle message.", e);
break
}
}
} catch (e: java.net.SocketException) {
@@ -486,6 +503,10 @@ class ChromecastCastingDevice : CastingDevice {
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e);
_socket?.close();
Logger.i(TAG, "Socket disconnected.");
connectionState = CastConnectionState.CONNECTING;
}
}
@@ -512,6 +533,7 @@ class ChromecastCastingDevice : CastingDevice {
if (_sessionId == null) {
connectionState = CastConnectionState.CONNECTED;
_sessionId = applicationUpdate.getString("sessionId");
_launchRetries = 0
val transportId = applicationUpdate.getString("transportId");
connectMediaChannel(transportId);
@@ -526,21 +548,40 @@ class ChromecastCastingDevice : CastingDevice {
}
if (!sessionIsRunning) {
_sessionId = null;
_mediaSessionId = null;
setTime(0.0);
_transportId = null;
Logger.w(TAG, "Session not found.");
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
_sessionId = null
_mediaSessionId = null
setTime(0.0)
_transportId = null
if (_launching) {
Logger.i(TAG, "Player not found, launching.");
launchPlayer();
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
_launchRetries++
launchPlayer()
} else if (!_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
// Maybe the first GET_STATUS came back empty; still try launching
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
_launching = true
_launchRetries++
launchPlayer()
} else {
Logger.e(TAG, "Player not found after $_launchRetries attempts; giving up.")
Logger.i(TAG, "Unable to start media receiver on device")
stop()
}
} else {
Logger.i(TAG, "Player not found, disconnecting.");
stop();
if (_retryJob == null) {
Logger.i(TAG, "Scheduled retry job over 5 seconds")
_retryJob = _scopeIO?.launch(Dispatchers.IO) {
delay(5000)
getStatus()
_retryJob = null
}
}
}
} else {
_launching = false;
_launching = false
_launchRetries = 0
}
val volume = status.getJSONObject("volume");
@@ -567,7 +608,7 @@ class ChromecastCastingDevice : CastingDevice {
}
isPlaying = playerState == "PLAYING";
if (isPlaying) {
if (isPlaying || playerState == "PAUSED") {
setTime(currentTime);
}
@@ -582,6 +623,8 @@ class ChromecastCastingDevice : CastingDevice {
if (message.sourceId == "receiver-0") {
Logger.i(TAG, "Close received.");
stop();
} else if (_transportId == message.sourceId) {
throw Exception("Transport id closed.")
}
}
} else {
@@ -616,6 +659,9 @@ class ChromecastCastingDevice : CastingDevice {
localAddress = null;
_started = false;
_retryJob?.cancel()
_retryJob = null
val socket = _socket;
val scopeIO = _scopeIO;
@@ -10,6 +10,8 @@ import android.os.Build
import android.os.Looper
import android.util.Base64
import android.util.Log
import java.net.NetworkInterface
import java.net.Inet4Address
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R
@@ -41,12 +43,11 @@ import com.futo.platformplayer.builders.DashBuilder
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.findPreferredAddress
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.states.StateSync.Companion
import com.futo.platformplayer.stores.CastingDeviceInfoStorage
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.toUrlAddress
@@ -57,9 +58,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.net.Inet6Address
import java.net.InetAddress
import java.net.URLDecoder
import java.net.URLEncoder
import java.util.Collections
import java.util.UUID
class StateCasting {
@@ -485,7 +488,7 @@ class StateCasting {
}
} else {
val proxyStreams = Settings.instance.casting.alwaysProxyRequests;
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
if (videoSource is IVideoUrlSource) {
@@ -580,7 +583,7 @@ class StateCasting {
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
val videoPath = "/video-${id}"
val videoUrl = url + videoPath;
@@ -599,7 +602,7 @@ class StateCasting {
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
val audioPath = "/audio-${id}"
val audioUrl = url + audioPath;
@@ -618,7 +621,7 @@ class StateCasting {
private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List<String> {
val ad = activeDevice ?: return listOf()
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"
val url = getLocalUrl(ad)
val id = UUID.randomUUID()
val hlsPath = "/hls-${id}"
@@ -714,7 +717,7 @@ class StateCasting {
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
val dashPath = "/dash-${id}"
@@ -764,7 +767,7 @@ class StateCasting {
val ad = activeDevice ?: return listOf();
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
val videoPath = "/video-${id}"
@@ -829,7 +832,7 @@ class StateCasting {
_castServer.removeAllHandlers("castProxiedHlsMaster")
val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
val hlsPath = "/hls-${id}"
@@ -999,7 +1002,7 @@ class StateCasting {
private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
val hlsPath = "/hls-${id}"
@@ -1129,7 +1132,7 @@ class StateCasting {
val ad = activeDevice ?: return listOf();
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
val dashPath = "/dash-${id}"
@@ -1215,6 +1218,15 @@ class StateCasting {
}
}
private fun getLocalUrl(ad: CastingDevice): String {
var address = ad.localAddress!!
if (address.isLinkLocalAddress) {
address = findPreferredAddress() ?: address
Logger.i(TAG, "Selected casting address: $address")
}
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
}
@OptIn(UnstableApi::class)
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf();
@@ -1222,7 +1234,7 @@ class StateCasting {
cleanExecutors()
_castServer.removeAllHandlers("castDashRaw")
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
val dashPath = "/dash-${id}"
@@ -7,7 +7,9 @@ import android.app.PendingIntent.getBroadcast
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.content.pm.PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED
import android.graphics.drawable.Animatable
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -155,6 +157,9 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller;
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
params.setRequireUserAction(USER_ACTION_NOT_REQUIRED)
}
val sessionId = packageInstaller.createSession(params);
session = packageInstaller.openSession(sessionId)
@@ -47,6 +47,7 @@ import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSpeed
import com.futo.polycentric.core.hexStringToByteArray
import hasAnySource
import isDownloadable
import kotlinx.coroutines.CancellationException
@@ -59,16 +60,21 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.serialization.Contextual
import kotlinx.serialization.Transient
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.lang.Thread.sleep
import java.nio.ByteBuffer
import java.time.OffsetDateTime
import java.util.UUID
import java.util.concurrent.Executors
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask
import java.util.concurrent.ThreadLocalRandom
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.coroutines.resumeWithException
import kotlin.time.times
@@ -564,6 +570,14 @@ class VideoDownload {
}
}
private fun decryptSegment(encryptedSegment: ByteArray, key: ByteArray, iv: ByteArray): ByteArray {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val secretKey = SecretKeySpec(key, "AES")
val ivSpec = IvParameterSpec(iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec)
return cipher.doFinal(encryptedSegment)
}
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists())
targetFile.delete();
@@ -579,6 +593,14 @@ class VideoDownload {
?: throw Exception("Variant playlist content is empty")
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) {
val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl)
check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" }
DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv?.hexStringToByteArray())
} else {
null
}
variantPlaylist.segments.forEachIndexed { index, segment ->
if (segment !is HLS.MediaSegment) {
return@forEachIndexed
@@ -590,7 +612,7 @@ class VideoDownload {
try {
segmentFiles.add(segmentFile)
val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri) { segmentLength, totalRead, lastSpeed ->
val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo, index) { segmentLength, totalRead, lastSpeed ->
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
@@ -630,10 +652,8 @@ class VideoDownload {
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation ->
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
val cmd =
"-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
val statisticsCallback = StatisticsCallback { _ ->
//TODO: Show progress?
@@ -643,7 +663,6 @@ class VideoDownload {
val session = FFmpegKit.executeAsync(cmd,
{ session ->
if (ReturnCode.isSuccess(session.returnCode)) {
fileList.delete()
continuation.resumeWith(Result.success(Unit))
} else {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
@@ -651,7 +670,6 @@ class VideoDownload {
} else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
}
fileList.delete()
continuation.resumeWithException(RuntimeException(errorMessage))
}
},
@@ -771,7 +789,7 @@ class VideoDownload {
else {
Logger.i(TAG, "Download $name Sequential");
try {
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, onProgress);
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, 0, onProgress);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
throw e
@@ -798,7 +816,31 @@ class VideoDownload {
}
return sourceLength!!;
}
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, onProgress: (Long, Long, Long) -> Unit): Long {
data class DecryptionInfo(
val key: ByteArray,
val iv: ByteArray?
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DecryptionInfo
if (!key.contentEquals(other.key)) return false
if (!iv.contentEquals(other.iv)) return false
return true
}
override fun hashCode(): Int {
var result = key.contentHashCode()
result = 31 * result + iv.contentHashCode()
return result
}
}
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long {
val progressRate: Int = 4096 * 5;
var lastProgressCount: Int = 0;
val speedRate: Int = 4096 * 5;
@@ -818,6 +860,8 @@ class VideoDownload {
val sourceLength = result.body.contentLength();
val sourceStream = result.body.byteStream();
val segmentBuffer = ByteArrayOutputStream()
var totalRead: Long = 0;
try {
var read: Int;
@@ -828,7 +872,7 @@ class VideoDownload {
if (read < 0)
break;
fileStream.write(buffer, 0, read);
segmentBuffer.write(buffer, 0, read);
totalRead += read;
@@ -854,6 +898,21 @@ class VideoDownload {
result.body.close()
}
if (decryptionInfo != null) {
var iv = decryptionInfo.iv
if (iv == null) {
iv = ByteBuffer.allocate(16)
.putLong(0L)
.putLong(index.toLong())
.array()
}
val decryptedData = decryptSegment(segmentBuffer.toByteArray(), decryptionInfo.key, iv!!)
fileStream.write(decryptedData)
} else {
fileStream.write(segmentBuffer.toByteArray())
}
onProgress(sourceLength, totalRead, 0);
return sourceLength;
}
@@ -1160,6 +1219,8 @@ class VideoDownload {
fun audioContainerToExtension(container: String): String {
if (container.contains("audio/mp4"))
return "mp4a";
else if (container.contains("video/mp4"))
return "mp4";
else if (container.contains("audio/mpeg"))
return "mpga";
else if (container.contains("audio/mp3"))
@@ -1167,7 +1228,7 @@ class VideoDownload {
else if (container.contains("audio/webm"))
return "webm";
else if (container == "application/vnd.apple.mpegurl")
return "mp4a";
return "m4a";
else
return "audio";// throw IllegalStateException("Unknown container: " + container)
}
@@ -69,7 +69,7 @@ class VideoExport {
outputFile = f;
} else if (v != null) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container);
val f = downloadRoot.createFile(v.container, outputFileName)
val f = downloadRoot.createFile(if (v.container == "application/vnd.apple.mpegurl") "video/mp4" else v.container, outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying video.");
@@ -81,8 +81,8 @@ class VideoExport {
outputFile = f;
} else if (a != null) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container);
val f = downloadRoot.createFile(a.container, outputFileName)
?: throw Exception("Failed to create file in external directory.");
val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "video/mp4" else a.container, outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying audio.");
@@ -188,6 +188,14 @@ class V8Plugin {
whenNotBusy {
synchronized(_runtimeLock) {
isStopped = true;
//Cleanup http
for(pack in _depsPackages) {
if(pack is PackageHttp) {
pack.cleanup();
}
}
_runtime?.let {
_runtime = null;
if(!it.isClosed && !it.isDead) {
@@ -12,6 +12,7 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.JSClientConstants
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@@ -77,6 +78,22 @@ class PackageBridge : V8Package {
return "android";
}
@V8Property
fun supportedContent(): Array<Int> {
return arrayOf(
ContentType.MEDIA.value,
ContentType.POST.value,
ContentType.PLAYLIST.value,
ContentType.WEB.value,
ContentType.URL.value,
ContentType.NESTED_VIDEO.value,
ContentType.CHANNEL.value,
ContentType.LOCKED.value,
ContentType.PLACEHOLDER.value,
ContentType.DEFERRED.value
)
}
@V8Function
fun dispose(value: V8Value) {
Logger.e(TAG, "Manual dispose: " + value.javaClass.name);
@@ -8,9 +8,7 @@ import com.caoccao.javet.enums.V8ProxyMode
import com.caoccao.javet.interop.V8Runtime
import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.reference.V8ValueArrayBuffer
import com.caoccao.javet.values.reference.V8ValueObject
import com.caoccao.javet.values.reference.V8ValueSharedArrayBuffer
import com.caoccao.javet.values.reference.V8ValueTypedArray
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@@ -20,15 +18,9 @@ import com.futo.platformplayer.engine.V8Plugin
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 {
@Transient
@@ -49,6 +41,9 @@ class PackageHttp: V8Package {
private var _batchPoolLock: Any = Any();
private var _batchPool: ForkJoinPool? = null;
private val aliveSockets = mutableListOf<SocketResult>();
private var _cleanedUp = false;
constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) {
_config = config;
@@ -58,6 +53,27 @@ class PackageHttp: V8Package {
_packageClientAuth = PackageHttpClient(this, _clientAuth);
}
fun cleanup(){
Logger.w(TAG, "PackageHttp Cleaning up")
val sockets = synchronized(aliveSockets) { aliveSockets.toList() }
_cleanedUp = true;
for(socket in sockets){
try {
Logger.w(TAG, "PackageHttp Socket Cleaned Up");
socket.close(1001, "Cleanup");
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to close socket", ex);
}
}
if(sockets.size > 0) {
//Thread.sleep(100); //Give sockets a bit
}
synchronized(aliveSockets) {
aliveSockets.clear();
}
}
/*
Automatically adjusting threadpool dedicated per PackageHttp for batch requests.
@@ -111,24 +127,24 @@ class PackageHttp: V8Package {
@V8Function
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
return if(useAuth)
_packageClientAuth.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
_packageClientAuth.requestInternal(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
else
_packageClient.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
_packageClient.requestInternal(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
}
@V8Function
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
return if(useAuth)
_packageClientAuth.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
_packageClientAuth.requestWithBodyInternal(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
else
_packageClient.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
_packageClient.requestWithBodyInternal(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
}
@V8Function
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse {
return if(useAuth)
_packageClientAuth.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING)
_packageClientAuth.GETInternal(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING)
else
_packageClient.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
_packageClient.GETInternal(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
}
@V8Function
fun POST(url: String, body: Any, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse {
@@ -136,15 +152,15 @@ class PackageHttp: V8Package {
val client = if(useAuth) _packageClientAuth else _packageClient;
if(body is V8ValueString)
return client.POST(url, body.value, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
return client.POSTInternal(url, body.value, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else if(body is String)
return client.POST(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
return client.POSTInternal(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else if(body is V8ValueTypedArray)
return client.POST(url, body.toBytes(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
return client.POSTInternal(url, body.toBytes(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else if(body is ByteArray)
return client.POST(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
return client.POSTInternal(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else if(body is ArrayList<*>) //Avoid this case, used purely for testing
return client.POST(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
return client.POSTInternal(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else
throw NotImplementedError("Body type " + body?.javaClass?.name?.toString() + " not implemented for POST");
}
@@ -276,9 +292,9 @@ class PackageHttp: V8Package {
if(it.second.method == "DUMMY")
return@autoParallelPool null;
if(it.second.body != null)
return@autoParallelPool it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType);
return@autoParallelPool it.first.requestWithBodyInternal(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType);
else
return@autoParallelPool it.first.request(it.second.method, it.second.url, it.second.headers, it.second.respType);
return@autoParallelPool it.first.requestInternal(it.second.method, it.second.url, it.second.headers, it.second.respType);
}.map {
if(it.second != null)
throw it.second!!;
@@ -345,7 +361,9 @@ class PackageHttp: V8Package {
}
@V8Function
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= requestInternal(method, url, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING);
fun requestInternal(method: String, url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
applyDefaultHeaders(headers);
return logExceptions {
return@logExceptions catchHttp {
@@ -364,7 +382,9 @@ class PackageHttp: V8Package {
};
}
@V8Function
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= requestWithBodyInternal(method, url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
fun requestWithBodyInternal(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
applyDefaultHeaders(headers);
return logExceptions {
catchHttp {
@@ -385,7 +405,9 @@ class PackageHttp: V8Package {
}
@V8Function
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= GETInternal(url, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
fun GETInternal(url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
applyDefaultHeaders(headers);
return logExceptions {
catchHttp {
@@ -407,7 +429,9 @@ class PackageHttp: V8Package {
};
}
@V8Function
fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
fun POSTInternal(url: String, body: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
applyDefaultHeaders(headers);
return logExceptions {
catchHttp {
@@ -429,7 +453,9 @@ class PackageHttp: V8Package {
};
}
@V8Function
fun POST(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
fun POST(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
fun POSTInternal(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
applyDefaultHeaders(headers);
return logExceptions {
catchHttp {
@@ -453,9 +479,16 @@ class PackageHttp: V8Package {
@V8Function
fun socket(url: String, headers: Map<String, String>? = null): SocketResult {
if(_package._cleanedUp)
throw IllegalStateException("Plugin shutdown");
val socketHeaders = headers?.toMutableMap() ?: HashMap();
applyDefaultHeaders(socketHeaders);
return SocketResult(this, _client, url, socketHeaders);
val socket = SocketResult(_package, this, _client, url, socketHeaders);
Logger.w(TAG, "PackageHttp Socket opened");
synchronized(_package.aliveSockets) {
_package.aliveSockets.add(socket);
}
return socket;
}
private fun applyDefaultHeaders(headerMap: MutableMap<String, String>) {
@@ -561,13 +594,15 @@ class PackageHttp: V8Package {
private var _listeners: V8ValueObject? = null;
private val _package: PackageHttp;
private val _packageClient: PackageHttpClient;
private val _client: ManagedHttpClient;
private val _url: String;
private val _headers: Map<String, String>;
constructor(pack: PackageHttpClient, client: ManagedHttpClient, url: String, headers: Map<String,String>) {
constructor(parent: PackageHttp, pack: PackageHttpClient, client: ManagedHttpClient, url: String, headers: Map<String,String>) {
_packageClient = pack;
_package = parent;
_client = client;
_url = url;
_headers = headers;
@@ -593,7 +628,7 @@ class PackageHttp: V8Package {
override fun open() {
Logger.i(TAG, "Websocket opened: " + _url);
_isOpen = true;
if(hasOpen) {
if(hasOpen && _listeners?.isClosed != true) {
try {
_listeners?.invokeVoid("open", arrayOf<Any>());
}
@@ -603,7 +638,7 @@ class PackageHttp: V8Package {
}
}
override fun message(msg: String) {
if(hasMessage) {
if(hasMessage && _listeners?.isClosed != true) {
try {
_listeners?.invokeVoid("message", msg);
}
@@ -611,7 +646,7 @@ class PackageHttp: V8Package {
}
}
override fun closing(code: Int, reason: String) {
if(hasClosing)
if(hasClosing && _listeners?.isClosed != true)
{
try {
_listeners?.invokeVoid("closing", code, reason);
@@ -623,7 +658,7 @@ class PackageHttp: V8Package {
}
override fun closed(code: Int, reason: String) {
_isOpen = false;
if(hasClosed) {
if(hasClosed && _listeners?.isClosed != true) {
try {
_listeners?.invokeVoid("closed", code, reason);
}
@@ -631,11 +666,15 @@ class PackageHttp: V8Package {
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
}
}
Logger.w(TAG, "PackageHttp Socket removed");
synchronized(_package.aliveSockets) {
_package.aliveSockets.remove(this@SocketResult);
}
}
override fun failure(exception: Throwable) {
_isOpen = false;
Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception);
if(hasFailure) {
if(hasFailure && _listeners?.isClosed != true) {
try {
_listeners?.invokeVoid("failure", exception.message);
}
@@ -5,6 +5,7 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
@@ -25,6 +26,7 @@ import com.futo.platformplayer.api.media.structures.MultiPager
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.exceptions.ChannelException
@@ -32,9 +34,11 @@ import com.futo.platformplayer.fragment.mainactivity.main.FeedView
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.SearchView
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
@@ -54,6 +58,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
private var _results: ArrayList<IPlatformContent> = arrayListOf();
private var _adapterResults: InsertedViewAdapterWithLoader<ContentPreviewViewHolder>? = null;
private var _lastPolycentricProfile: PolycentricProfile? = null;
private var _query: String? = null
private var _searchView: SearchView? = null
val onContentClicked = Event2<IPlatformContent, Long>();
val onContentUrlClicked = Event2<String, ContentType>();
@@ -68,16 +74,32 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
Logger.i(TAG, "getContentPager");
val lastPolycentricProfile = _lastPolycentricProfile;
var pager: IPager<IPlatformContent>? = null;
if (lastPolycentricProfile != null)
pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile);
var pager: IPager<IPlatformContent>? = null
val query = _query
if (!query.isNullOrBlank()) {
if(subType != null) {
Logger.i(TAG, "StatePlatform.instance.searchChannel(channel.url = ${channel.url}, query = ${query}, subType = ${subType})")
pager = StatePlatform.instance.searchChannel(channel.url, query, subType);
} else {
Logger.i(TAG, "StatePlatform.instance.searchChannel(channel.url = ${channel.url}, query = ${query})")
pager = StatePlatform.instance.searchChannel(channel.url, query);
}
} else {
val lastPolycentricProfile = _lastPolycentricProfile;
if (lastPolycentricProfile != null && StatePolycentric.instance.enabled) {
pager = StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = subType);
Logger.i(TAG, "StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = ${subType})")
}
if(pager == null) {
if(subType != null)
pager = StatePlatform.instance.getChannelContent(channel.url, subType);
else
pager = StatePlatform.instance.getChannelContent(channel.url);
if(pager == null) {
if(subType != null) {
pager = StatePlatform.instance.getChannelContent(channel.url, subType);
Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url}, subType = ${subType})")
} else {
pager = StatePlatform.instance.getChannelContent(channel.url);
Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url})")
}
}
}
return pager;
}
@@ -144,19 +166,49 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
_taskLoadVideos.cancel();
_query = null
_channel = channel;
updateSearchViewVisibility()
_results.clear();
_adapterResults?.notifyDataSetChanged();
loadInitial();
}
private fun updateSearchViewVisibility() {
if (subType != null) {
_searchView?.visibility = View.GONE
return
}
val client = _channel?.id?.pluginId?.let { StatePlatform.instance.getClientOrNull(it) }
Logger.i(TAG, "_searchView.visible = ${client?.capabilities?.hasSearchChannelContents == true}")
_searchView?.visibility = if (client?.capabilities?.hasSearchChannelContents == true) View.VISIBLE else View.GONE
}
fun setQuery(query: String) {
_query = query
_taskLoadVideos.cancel()
_results.clear()
_adapterResults?.notifyDataSetChanged()
loadInitial()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_channel_videos, container, false);
_query = null
_recyclerResults = view.findViewById(R.id.recycler_videos);
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar).apply {
val searchView = SearchView(requireContext()).apply { layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT) }.apply {
onEnter.subscribe {
setQuery(it)
}
}
_searchView = searchView
updateSearchViewVisibility()
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply {
this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
@@ -173,6 +225,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
_recyclerResults?.layoutManager = _glmVideo;
_recyclerResults?.addOnScrollListener(_scrollListener);
return view;
}
@@ -181,6 +234,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
_recyclerResults?.removeOnScrollListener(_scrollListener);
_recyclerResults = null;
_pager = null;
_query = null
_searchView = null
_taskLoadVideos.cancel();
_nextPageHandler.cancel();
@@ -303,6 +358,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
}
private fun loadInitial() {
Logger.i(TAG, "loadInitial")
val channel: IPlatformChannel = _channel ?: return;
setLoading(true);
_taskLoadVideos.run(channel);
@@ -330,7 +330,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
}
if (!StatePayment.instance.hasPaid) {
newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid_filled, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>() }))
newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid_filled, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>(withHistory = false) }))
}
//Add conditional buttons here, when you add a conditional button, be sure to add the register and unregister events for when the button needs to be updated
@@ -383,18 +383,18 @@ class MenuBottomBarFragment : MainActivityFragment() {
currentMain.scrollToTop(false)
currentMain.reloadFeed()
} else {
it.navigate<HomeFragment>()
it.navigate<HomeFragment>(withHistory = false)
}
}),
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 = 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(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate<SubscriptionsFeedFragment>(withHistory = false) }),
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>(withHistory = false) }),
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>(withHistory = false) }),
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>(withHistory = false) }),
ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>(withHistory = false) }),
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>(withHistory = false) }),
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }),
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>(withHistory = false) }),
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>(withHistory = false) }),
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()");
@@ -416,7 +416,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
}, 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);
it.navigate<BrowserFragment>(Settings.URL_FAQ, withHistory = false);
})
//96 is reserved for privacy button
//98 is reserved for buy button
@@ -0,0 +1,814 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.Animatable
import android.os.Bundle
import android.text.Html
import android.text.method.ScrollingMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewPropertyAnimator
import android.widget.Button
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.children
import androidx.core.view.isVisible
import androidx.core.view.setPadding
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails
import com.futo.platformplayer.api.media.models.post.TextType
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.models.JSArticleDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSHeaderSegment
import com.futo.platformplayer.api.media.platforms.js.models.JSImagesSegment
import com.futo.platformplayer.api.media.platforms.js.models.JSNestedSegment
import com.futo.platformplayer.api.media.platforms.js.models.JSTextSegment
import com.futo.platformplayer.api.media.platforms.js.models.SegmentType
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.fixHtmlWhitespace
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
import com.futo.platformplayer.sp
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.feedtypes.PreviewLockedView
import com.futo.platformplayer.views.adapters.feedtypes.PreviewNestedVideoView
import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView
import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoView
import com.futo.platformplayer.views.comments.AddCommentView
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.segments.CommentsList
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Models
import com.futo.polycentric.core.Opinion
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import com.google.android.flexbox.FlexboxLayout
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.protobuf.ByteString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import userpackage.Protocol
import java.lang.Integer.min
class ArticleDetailFragment : MainFragment {
override val isMainView: Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var _viewDetail: ArticleDetailView? = null;
constructor() : super() { }
override fun onBackPressed(): Boolean {
return false;
}
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = ArticleDetailView(inflater.context).applyFragment(this);
_viewDetail = view;
return view;
}
override fun onDestroyMainView() {
super.onDestroyMainView();
_viewDetail?.onDestroy();
_viewDetail = null;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
if (parameter is IPlatformArticleDetails) {
_viewDetail?.clear();
_viewDetail?.setArticleDetails(parameter);
} else if (parameter is IPlatformArticle) {
_viewDetail?.setArticleOverview(parameter);
} else if(parameter is String) {
_viewDetail?.setPostUrl(parameter);
}
}
private class ArticleDetailView : ConstraintLayout {
private lateinit var _fragment: ArticleDetailFragment;
private var _url: String? = null;
private var _isLoading = false;
private var _article: IPlatformArticleDetails? = null;
private var _articleOverview: IPlatformArticle? = null;
private var _polycentricProfile: PolycentricProfile? = null;
private var _version = 0;
private var _isRepliesVisible: Boolean = false;
private var _repliesAnimator: ViewPropertyAnimator? = null;
private val _creatorThumbnail: CreatorThumbnail;
private val _buttonSubscribe: SubscribeButton;
private val _channelName: TextView;
private val _channelMeta: TextView;
private val _textTitle: TextView;
private val _textMeta: TextView;
private val _textSummary: TextView;
private val _containerSegments: LinearLayout;
private val _platformIndicator: PlatformIndicator;
private val _buttonShare: ImageButton;
private val _layoutRating: LinearLayout;
private val _imageLikeIcon: ImageView;
private val _textLikes: TextView;
private val _imageDislikeIcon: ImageView;
private val _textDislikes: TextView;
private val _addCommentView: AddCommentView;
private val _rating: PillRatingLikesDislikes;
private val _layoutLoadingOverlay: FrameLayout;
private val _imageLoader: ImageView;
private var _overlayContainer: FrameLayout
private val _repliesOverlay: RepliesOverlay;
private val _commentsList: CommentsList;
private var _commentType: Boolean? = null;
private val _buttonPolycentric: Button
private val _buttonPlatform: Button
private val _taskLoadPost = if(!isInEditMode) TaskHandler<String, IPlatformArticleDetails>(
StateApp.instance.scopeGetter,
{
val result = StatePlatform.instance.getContentDetails(it).await();
if(result !is IPlatformArticleDetails)
throw IllegalStateException(context.getString(R.string.expected_media_content_found) + " ${result.contentType}");
return@TaskHandler result;
})
.success { setArticleDetails(it) }
.exception<Throwable> {
Logger.w(ChannelFragment.TAG, context.getString(R.string.failed_to_load_post), it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, {
if (!StatePolycentric.instance.enabled)
return@TaskHandler null
ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!)
})
.success { it -> setPolycentricProfile(it, animate = true) }
.exception<Throwable> {
Logger.w(TAG, "Failed to load claims.", it);
};
constructor(context: Context) : super(context) {
inflate(context, R.layout.fragview_article_detail, this);
val root = findViewById<FrameLayout>(R.id.root);
_creatorThumbnail = findViewById(R.id.creator_thumbnail);
_buttonSubscribe = findViewById(R.id.button_subscribe);
_channelName = findViewById(R.id.text_channel_name);
_channelMeta = findViewById(R.id.text_channel_meta);
_textTitle = findViewById(R.id.text_title);
_textMeta = findViewById(R.id.text_meta);
_textSummary = findViewById(R.id.text_summary);
_containerSegments = findViewById(R.id.container_segments);
_platformIndicator = findViewById(R.id.platform_indicator);
_buttonShare = findViewById(R.id.button_share);
_overlayContainer = findViewById(R.id.overlay_container);
_layoutRating = findViewById(R.id.layout_rating);
_imageLikeIcon = findViewById(R.id.image_like_icon);
_textLikes = findViewById(R.id.text_likes);
_imageDislikeIcon = findViewById(R.id.image_dislike_icon);
_textDislikes = findViewById(R.id.text_dislikes);
_commentsList = findViewById(R.id.comments_list);
_addCommentView = findViewById(R.id.add_comment_view);
_rating = findViewById(R.id.rating);
_layoutLoadingOverlay = findViewById(R.id.layout_loading_overlay);
_imageLoader = findViewById(R.id.image_loader);
_repliesOverlay = findViewById(R.id.replies_overlay);
_buttonPolycentric = findViewById(R.id.button_polycentric)
_buttonPlatform = findViewById(R.id.button_platform)
_buttonSubscribe.onSubscribed.subscribe {
//TODO: add overlay to layout
//UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
};
val layoutTop: LinearLayout = findViewById(R.id.layout_top);
root.removeView(layoutTop);
_commentsList.setPrependedView(layoutTop);
/*TODO: Why is this here?
_commentsList.onCommentsLoaded.subscribe {
updateCommentType(false);
};*/
_commentsList.onRepliesClick.subscribe { c ->
val replyCount = c.replyCount ?: 0;
var metadata = "";
if (replyCount > 0) {
metadata += "$replyCount " + context.getString(R.string.replies);
}
if (c is PolycentricPlatformComment) {
var parentComment: PolycentricPlatformComment = c;
_repliesOverlay.load(_commentType!!, metadata, c.contextUrl, c.reference, c,
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
{
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
_commentsList.replaceComment(parentComment, newComment);
parentComment = newComment;
});
} else {
_repliesOverlay.load(_commentType!!, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
}
setRepliesOverlayVisible(isVisible = true, animate = true);
};
if (StatePolycentric.instance.enabled) {
_buttonPolycentric.setOnClickListener {
updateCommentType(false)
}
} else {
_buttonPolycentric.visibility = View.GONE
}
_buttonPlatform.setOnClickListener {
updateCommentType(true)
}
_addCommentView.onCommentAdded.subscribe {
_commentsList.addComment(it);
};
_repliesOverlay.onClose.subscribe { setRepliesOverlayVisible(isVisible = false, animate = true); };
_buttonShare.setOnClickListener { share() };
_creatorThumbnail.onClick.subscribe { openChannel() };
_channelName.setOnClickListener { openChannel() };
_channelMeta.setOnClickListener { openChannel() };
}
private fun openChannel() {
val author = _article?.author ?: _articleOverview?.author ?: return;
_fragment.navigate<ChannelFragment>(author);
}
private fun share() {
try {
Logger.i(PreviewPostView.TAG, "sharePost")
val url = _article?.shareUrl ?: _articleOverview?.shareUrl ?: _url;
_fragment.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND;
putExtra(Intent.EXTRA_TEXT, url);
type = "text/plain"; //TODO: Determine alt types?
}, null));
} catch (e: Throwable) {
//Ignored
Logger.e(PreviewPostView.TAG, "Failed to share.", e);
}
}
private fun updatePolycentricRating() {
_rating.visibility = View.GONE;
val ref = Models.referenceFromBuffer((_article?.url ?: _articleOverview?.url)?.toByteArray() ?: return)
val extraBytesRef = (_article?.id?.value ?: _articleOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null }
val version = _version;
_rating.onLikeDislikeUpdated.remove(this);
if (!StatePolycentric.instance.enabled)
return
_fragment.lifecycleScope.launch(Dispatchers.IO) {
if (version != _version) {
return@launch;
}
try {
val queryReferencesResponse = ApiMethods.getQueryReferences(ApiMethods.SERVER, ref, null,null,
arrayListOf(
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(
ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.like.data)).build(),
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(
ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.dislike.data)).build()
),
extraByteReferences = listOfNotNull(extraBytesRef)
);
if (version != _version) {
return@launch;
}
val likes = queryReferencesResponse.countsList[0];
val dislikes = queryReferencesResponse.countsList[1];
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
withContext(Dispatchers.Main) {
if (version != _version) {
return@withContext;
}
_rating.visibility = VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) {
args.processHandle.opinion(ref, Opinion.dislike);
} else {
args.processHandle.opinion(ref, Opinion.neutral);
}
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e)
}
}
StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked)
};
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
_rating.visibility = View.GONE;
}
}
}
private fun setPlatformRating(rating: IRating?) {
if (rating == null) {
_layoutRating.visibility = View.GONE;
return;
}
_layoutRating.visibility = View.VISIBLE;
when (rating) {
is RatingLikeDislikes -> {
_textLikes.visibility = View.VISIBLE;
_imageLikeIcon.visibility = View.VISIBLE;
_textLikes.text = rating.likes.toHumanNumber();
_imageDislikeIcon.visibility = View.VISIBLE;
_textDislikes.visibility = View.VISIBLE;
_textDislikes.text = rating.dislikes.toHumanNumber();
}
is RatingLikes -> {
_textLikes.visibility = View.VISIBLE;
_imageLikeIcon.visibility = View.VISIBLE;
_textLikes.text = rating.likes.toHumanNumber();
_imageDislikeIcon.visibility = View.GONE;
_textDislikes.visibility = View.GONE;
}
else -> {
_textLikes.visibility = View.GONE;
_imageLikeIcon.visibility = View.GONE;
_imageDislikeIcon.visibility = View.GONE;
_textDislikes.visibility = View.GONE;
}
}
}
fun applyFragment(frag: ArticleDetailFragment): ArticleDetailView {
_fragment = frag;
return this;
}
fun clear() {
_commentsList.cancel();
_taskLoadPost.cancel();
_taskLoadPolycentricProfile.cancel();
_version++;
updateCommentType(null)
_url = null;
_article = null;
_articleOverview = null;
_creatorThumbnail.clear();
//_buttonSubscribe.setSubscribeChannel(null); TODO: clear button
_channelName.text = "";
setChannelMeta(null);
_textTitle.text = "";
_textMeta.text = "";
setPlatformRating(null);
_polycentricProfile = null;
_rating.visibility = View.GONE;
updatePolycentricRating();
setRepliesOverlayVisible(isVisible = false, animate = false);
_containerSegments.removeAllViews();
_addCommentView.setContext(null, null);
_platformIndicator.clearPlatform();
}
fun setArticleDetails(value: IPlatformArticleDetails) {
_url = value.url;
_article = value;
_creatorThumbnail.setThumbnail(value.author.thumbnail, false);
_buttonSubscribe.setSubscribeChannel(value.author.url);
_channelName.text = value.author.name;
setChannelMeta(value);
_textTitle.text = value.name;
_textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count?
_textSummary.text = value.summary
_textSummary.isVisible = !value.summary.isNullOrEmpty()
_platformIndicator.setPlatformFromClientID(value.id.pluginId);
setPlatformRating(value.rating);
for(seg in value.segments) {
when(seg.type) {
SegmentType.HEADER -> {
if(seg is JSHeaderSegment) {
_containerSegments.addView(ArticleHeaderBlock(context, seg.content, seg.level))
}
}
SegmentType.TEXT -> {
if(seg is JSTextSegment) {
_containerSegments.addView(ArticleTextBlock(context, seg.content, seg.textType))
}
}
SegmentType.IMAGES -> {
if(seg is JSImagesSegment) {
if(seg.images.size > 0)
_containerSegments.addView(ArticleImageBlock(context, seg.images[0], seg.caption))
}
}
SegmentType.NESTED -> {
if(seg is JSNestedSegment) {
_containerSegments.addView(ArticleContentBlock(context, seg.nested, _fragment, _overlayContainer));
}
}
else ->{}
}
}
//Fetch only when not already called in setPostOverview
if (_articleOverview == null) {
fetchPolycentricProfile();
updatePolycentricRating();
_addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray()));
}
val commentType = !Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1
updateCommentType(commentType, true);
setLoading(false);
}
fun setArticleOverview(value: IPlatformArticle) {
clear();
_url = value.url;
_articleOverview = value;
_creatorThumbnail.setThumbnail(value.author.thumbnail, false);
_buttonSubscribe.setSubscribeChannel(value.author.url);
_channelName.text = value.author.name;
setChannelMeta(value);
_textTitle.text = value.name;
_textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count?
_platformIndicator.setPlatformFromClientID(value.id.pluginId);
_addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray()));
updatePolycentricRating();
fetchPolycentricProfile();
fetchPost();
}
private fun setRepliesOverlayVisible(isVisible: Boolean, animate: Boolean) {
if (_isRepliesVisible == isVisible) {
return;
}
_isRepliesVisible = isVisible;
_repliesAnimator?.cancel();
if (isVisible) {
_repliesOverlay.visibility = View.VISIBLE;
if (animate) {
_repliesOverlay.translationY = _repliesOverlay.height.toFloat();
_repliesAnimator = _repliesOverlay.animate()
.setDuration(300)
.translationY(0f)
.withEndAction {
_repliesAnimator = null;
}.apply { start() };
}
} else {
if (animate) {
_repliesOverlay.translationY = 0f;
_repliesAnimator = _repliesOverlay.animate()
.setDuration(300)
.translationY(_repliesOverlay.height.toFloat())
.withEndAction {
_repliesOverlay.visibility = GONE;
_repliesAnimator = null;
}.apply { start(); }
} else {
_repliesOverlay.visibility = View.GONE;
_repliesOverlay.translationY = _repliesOverlay.height.toFloat();
}
}
}
private fun fetchPolycentricProfile() {
val author = _article?.author ?: _articleOverview?.author ?: return;
setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(author.id);
}
private fun setChannelMeta(value: IPlatformArticle?) {
val subscribers = value?.author?.subscribers;
if(subscribers != null && subscribers > 0) {
_channelMeta.visibility = View.VISIBLE;
_channelMeta.text = if((value.author.subscribers ?: 0) > 0) value.author.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers) else "";
} else {
_channelMeta.visibility = View.GONE;
_channelMeta.text = "";
}
}
fun setPostUrl(url: String) {
clear();
_url = url;
fetchPost();
}
fun onDestroy() {
_commentsList.cancel();
_taskLoadPost.cancel();
_repliesOverlay.cleanup();
}
private fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) {
_polycentricProfile = polycentricProfile;
val pp = _polycentricProfile;
if (pp == null) {
_creatorThumbnail.setHarborAvailable(false, animate, null);
return;
}
_creatorThumbnail.setHarborAvailable(true, animate, pp.system.toProto());
}
private fun fetchPost() {
Logger.i(TAG, "fetchVideo")
_article = null;
val url = _url;
if (!url.isNullOrBlank()) {
setLoading(true);
_taskLoadPost.run(url);
}
}
private fun fetchComments() {
Logger.i(TAG, "fetchComments")
_article?.let {
_commentsList.load(true) { StatePlatform.instance.getComments(it); };
}
}
private fun fetchPolycentricComments() {
Logger.i(TAG, "fetchPolycentricComments")
val post = _article;
val ref = (_article?.url ?: _articleOverview?.url)?.toByteArray()?.let { Models.referenceFromBuffer(it) }
val extraBytesRef = (_article?.id?.value ?: _articleOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null }
if (ref == null) {
Logger.w(TAG, "Failed to fetch polycentric comments because url was not set null")
_commentsList.clear();
return
}
_commentsList.load(false) { StatePolycentric.instance.getCommentPager(post!!.url, ref, listOfNotNull(extraBytesRef)); };
}
private fun updateCommentType(commentType: Boolean?, forceReload: Boolean = false) {
val changed = commentType != _commentType
_commentType = commentType
if (commentType == null) {
_buttonPlatform.setTextColor(resources.getColor(R.color.gray_ac))
_buttonPolycentric.setTextColor(resources.getColor(R.color.gray_ac))
} else {
_buttonPlatform.setTextColor(resources.getColor(if (commentType) R.color.white else R.color.gray_ac))
_buttonPolycentric.setTextColor(resources.getColor(if (!commentType) R.color.white else R.color.gray_ac))
if (commentType) {
_addCommentView.visibility = View.GONE;
if (forceReload || changed) {
fetchComments();
}
} else {
_addCommentView.visibility = View.VISIBLE;
if (forceReload || changed) {
fetchPolycentricComments()
}
}
}
}
private fun setLoading(isLoading : Boolean) {
if (_isLoading == isLoading) {
return;
}
_isLoading = isLoading;
if(isLoading) {
(_imageLoader.drawable as Animatable?)?.start()
_layoutLoadingOverlay.visibility = View.VISIBLE;
}
else {
_layoutLoadingOverlay.visibility = View.GONE;
(_imageLoader.drawable as Animatable?)?.stop()
}
}
class ArticleHeaderBlock : LinearLayout {
constructor(context: Context?, content: String, level: Int) : super(context){
inflate(context, R.layout.view_segment_text, this);
findViewById<TextView>(R.id.text_content)?.let {
it.text = content;
val sp = when(level) {
1 -> 6.sp(resources);
2 -> 8.sp(resources);
3 -> 10.sp(resources);
4 -> 12.sp(resources);
5 -> 14.sp(resources);
else -> 6.sp(resources);
}
it.setTextColor(Color.WHITE);
it.setTypeface(Typeface.create(null, 600, false));
it.textSize = sp.toFloat();
}
}
}
class ArticleTextBlock : LinearLayout {
constructor(context: Context?, content: String, textType: TextType) : super(context){
inflate(context, R.layout.view_segment_text, this);
findViewById<TextView>(R.id.text_content)?.let {
if(textType == TextType.HTML)
it.text = Html.fromHtml(content, Html.FROM_HTML_MODE_COMPACT);
else if(textType == TextType.CODE) {
it.text = content;
it.setPadding(15.dp(resources));
it.setHorizontallyScrolling(true);
it.movementMethod = ScrollingMovementMethod();
it.setTypeface(Typeface.MONOSPACE);
it.setBackgroundResource(R.drawable.background_videodetail_description)
}
else
it.text = content;
}
}
}
class ArticleImageBlock: LinearLayout {
constructor(context: Context?, image: String, caption: String? = null) : super(context){
inflate(context, R.layout.view_segment_image, this);
findViewById<ImageView>(R.id.image_content)?.let {
Glide.with(it)
.load(image)
.crossfade()
.into(it);
}
findViewById<TextView>(R.id.text_content)?.let {
if(caption?.isNullOrEmpty() == true)
it.isVisible = false;
else
it.text = caption;
}
}
}
class ArticleContentBlock: LinearLayout {
constructor(context: Context, content: IPlatformContent?, fragment: ArticleDetailFragment? = null, overlayContainer: FrameLayout? = null): super(context) {
if(content != null) {
var view: View? = null;
if(content is IPlatformNestedContent) {
view = PreviewNestedVideoView(context, FeedStyle.THUMBNAIL, null);
view.bind(content);
view.onContentUrlClicked.subscribe { a,b -> }
}
else if(content is IPlatformVideo) {
view = PreviewVideoView(context, FeedStyle.THUMBNAIL, null, true);
view.bind(content);
view.onVideoClicked.subscribe { a,b -> fragment?.navigate<VideoDetailFragment>(a) }
view.onChannelClicked.subscribe { a -> fragment?.navigate<ChannelFragment>(a) }
if(overlayContainer != null) {
view.onAddToClicked.subscribe { a -> UISlideOverlays.showVideoOptionsOverlay(a, overlayContainer) };
}
view.onAddToQueueClicked.subscribe { a -> StatePlayer.instance.addToQueue(a) }
view.onAddToWatchLaterClicked.subscribe { a ->
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true))
UIDialogs.toast("Added to watch later\n[${content.name}]")
}
}
else if(content is IPlatformPost) {
view = PreviewPostView(context, FeedStyle.THUMBNAIL);
view.bind(content);
view.onContentClicked.subscribe { a -> fragment?.navigate<PostDetailFragment>(a) }
view.onChannelClicked.subscribe { a -> fragment?.navigate<ChannelFragment>(a) }
}
else if(content is IPlatformArticle) {
view = PreviewPostView(context, FeedStyle.THUMBNAIL);
view.bind(content);
view.onContentClicked.subscribe { a -> fragment?.navigate<ArticleDetailFragment>(a) }
view.onChannelClicked.subscribe { a -> fragment?.navigate<ChannelFragment>(a) }
}
else if(content is IPlatformLockedContent) {
view = PreviewLockedView(context, FeedStyle.THUMBNAIL);
view.bind(content);
}
if(view != null)
addView(view);
}
}
}
companion object {
const val TAG = "PostDetailFragment"
}
}
companion object {
fun newInstance() = ArticleDetailFragment().apply {}
}
}
@@ -47,6 +47,7 @@ import com.futo.platformplayer.selectHighestResolutionImage
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.adapters.ChannelTab
@@ -135,6 +136,8 @@ class ChannelFragment : MainFragment() {
inflater.inflate(R.layout.fragment_channel, this)
_taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>({ fragment.lifecycleScope },
{ id ->
if (!StatePolycentric.instance.enabled)
return@TaskHandler null
return@TaskHandler ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, id.claimFieldType.toLong(), id.claimType.toLong(), id.value!!)
}).success { setPolycentricProfile(it, animate = true) }.exception<Throwable> {
Logger.w(TAG, "Failed to load polycentric profile.", it)
@@ -422,17 +425,15 @@ class ChannelFragment : MainFragment() {
_fragment.lifecycleScope.launch(Dispatchers.IO) {
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url)
withContext(Dispatchers.Main) {
if (plugin != null && plugin.capabilities.hasSearchChannelContents) {
buttons.add(Pair(R.drawable.ic_search) {
_fragment.navigate<SuggestionsFragment>(
SuggestionsFragmentData(
"", SearchType.VIDEO, channel.url
)
buttons.add(Pair(R.drawable.ic_search) {
_fragment.navigate<SuggestionsFragment>(
SuggestionsFragmentData(
"", SearchType.VIDEO
)
})
)
})
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
}
if(plugin != null && plugin.capabilities.hasGetChannelCapabilities) {
if(plugin.getChannelCapabilities()?.types?.contains(ResultCapabilities.TYPE_SHORTS) ?: false &&
!(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(ChannelTab.SHORTS.ordinal.toLong())) {
@@ -10,12 +10,14 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateMeta
@@ -196,7 +198,14 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
fragment.navigate<RemotePlaylistFragment>(content);
} else if (content is IPlatformPost) {
fragment.navigate<PostDetailFragment>(content);
} else if(content is IPlatformArticle) {
fragment.navigate<ArticleDetailFragment>(content);
}
else if(content is JSWeb) {
fragment.navigate<WebDetailFragment>(content);
}
else
UIDialogs.appToast("Unknown content type [" + content.contentType.name + "]");
}
protected open fun onContentUrlClicked(url: String, contentType: ContentType) {
when(contentType) {
@@ -89,7 +89,6 @@ class ContentSearchResultsFragment : MainFragment() {
private var _sortBy: String? = null;
private var _filterValues: HashMap<String, List<String>> = hashMapOf();
private var _enabledClientIds: List<String>? = null;
private var _channelUrl: String? = null;
private var _searchType: SearchType? = null;
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
@@ -98,17 +97,12 @@ class ContentSearchResultsFragment : MainFragment() {
constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
_taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query ->
Logger.i(TAG, "Searching for: $query")
val channelUrl = _channelUrl;
if (channelUrl != null) {
StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds)
} else {
when (_searchType)
{
SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
SearchType.CREATOR -> StatePlatform.instance.searchChannelsAsContent(query)
SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query)
else -> throw Exception("Search type must be specified")
}
when (_searchType)
{
SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
SearchType.CREATOR -> StatePlatform.instance.searchChannelsAsContent(query)
SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query)
else -> throw Exception("Search type must be specified")
}
})
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
@@ -147,7 +141,6 @@ class ContentSearchResultsFragment : MainFragment() {
fun onShown(parameter: Any?) {
if(parameter is SuggestionsFragmentData) {
setQuery(parameter.query, false);
setChannelUrl(parameter.channelUrl, false);
setSearchType(parameter.searchType, false)
fragment.topBar?.apply {
@@ -164,7 +157,7 @@ class ContentSearchResultsFragment : MainFragment() {
onFilterClick.subscribe(this) {
_overlayContainer.let {
val filterValuesCopy = HashMap(_filterValues);
val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy, _channelUrl != null);
val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy);
filtersOverlay.onOK.subscribe { enabledClientIds, changed ->
if (changed) {
setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy);
@@ -211,11 +204,7 @@ class ContentSearchResultsFragment : MainFragment() {
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
val commonCapabilities =
if(_channelUrl == null)
StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
else
StatePlatform.instance.getCommonSearchChannelContentsCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
val commonCapabilities = StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
val sorts = commonCapabilities?.sorts ?: listOf();
if (sorts.size > 1) {
withContext(Dispatchers.Main) {
@@ -282,15 +271,6 @@ class ContentSearchResultsFragment : MainFragment() {
}
}
private fun setChannelUrl(channelUrl: String?, updateResults: Boolean = true) {
_channelUrl = channelUrl;
if (updateResults) {
clearResults();
loadResults();
}
}
private fun setSearchType(searchType: SearchType, updateResults: Boolean = true) {
_searchType = searchType
@@ -217,7 +217,7 @@ class PlaylistsFragment : MainFragment() {
var playlistsToReturn = pls;
if(!_listPlaylistsSearch.text.isNullOrEmpty())
playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) };
if(!_ordering.value.isNullOrEmpty()){
if(!_ordering.value.isNullOrEmpty()) {
playlistsToReturn = when(_ordering.value){
"nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() }
"nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() };
@@ -168,7 +168,12 @@ class PostDetailFragment : MainFragment {
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) })
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, {
if (!StatePolycentric.instance.enabled)
return@TaskHandler null
ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!)
})
.success { it -> setPolycentricProfile(it, animate = true) }
.exception<Throwable> {
Logger.w(TAG, "Failed to load claims.", it);
@@ -327,6 +332,10 @@ class PostDetailFragment : MainFragment {
val version = _version;
_rating.onLikeDislikeUpdated.remove(this);
if (!StatePolycentric.instance.enabled)
return
_fragment.lifecycleScope.launch(Dispatchers.IO) {
if (version != _version) {
return@launch;
@@ -290,8 +290,8 @@ class SubscriptionGroupFragment : MainFragment() {
image.setImageView(_imageGroup);
}
else {
_imageGroupBackground.setImageResource(0);
_imageGroup.setImageResource(0);
_imageGroupBackground.setImageDrawable(null);
_imageGroup.setImageDrawable(null);
}
updateMeta();
reloadCreators(group);
@@ -21,7 +21,7 @@ import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter
import com.futo.platformplayer.views.others.RadioGroupView
import com.futo.platformplayer.views.others.TagsView
data class SuggestionsFragmentData(val query: String, val searchType: SearchType, val channelUrl: String? = null);
data class SuggestionsFragmentData(val query: String, val searchType: SearchType);
class SuggestionsFragment : MainFragment {
override val isMainView : Boolean = true;
@@ -34,7 +34,6 @@ class SuggestionsFragment : MainFragment {
private val _suggestions: ArrayList<String> = ArrayList();
private var _query: String? = null;
private var _searchType: SearchType = SearchType.VIDEO;
private var _channelUrl: String? = null;
private val _adapterSuggestions = SearchSuggestionAdapter(_suggestions);
@@ -52,7 +51,7 @@ class SuggestionsFragment : MainFragment {
_adapterSuggestions.onClicked.subscribe { suggestion ->
val storage = FragmentedStorage.get<SearchHistoryStorage>();
storage.add(suggestion);
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, _searchType, _channelUrl));
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, _searchType));
}
_adapterSuggestions.onRemove.subscribe { suggestion ->
val index = _suggestions.indexOf(suggestion);
@@ -109,10 +108,8 @@ class SuggestionsFragment : MainFragment {
if (parameter is SuggestionsFragmentData) {
_searchType = parameter.searchType;
_channelUrl = parameter.channelUrl;
} else if (parameter is SearchType) {
_searchType = parameter;
_channelUrl = null;
}
_radioGroupView?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true)
@@ -135,7 +132,7 @@ class SuggestionsFragment : MainFragment {
}
}
else
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, _searchType, _channelUrl));
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, _searchType));
};
onTextChange.subscribe(this) {
@@ -455,6 +455,10 @@ class VideoDetailFragment() : MainFragment() {
activity?.enterPictureInPictureMode(params);
}
}
if (isFullscreen) {
viewDetail?.restoreBrightness()
}
}
fun forcePictureInPicture() {
@@ -487,6 +491,10 @@ class VideoDetailFragment() : MainFragment() {
_isActive = true;
_leavingPiP = false;
if (isFullscreen) {
_viewDetail?.saveBrightness()
}
_viewDetail?.let {
Logger.v(TAG, "onResume preventPictureInPicture=false");
it.preventPictureInPicture = false;
@@ -47,7 +47,6 @@ import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.LiveChatManager
import com.futo.platformplayer.api.media.PlatformID
@@ -150,7 +149,6 @@ import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
import com.futo.platformplayer.views.pills.RoundButton
import com.futo.platformplayer.views.pills.RoundButtonGroup
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.segments.ChaptersList
import com.futo.platformplayer.views.segments.CommentsList
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.platformplayer.views.video.FutoVideoPlayer
@@ -649,6 +647,15 @@ class VideoDetailView : ConstraintLayout {
_timeBar.setDuration(video?.duration ?: 0);
}
};
_cast.onTimeJobTimeChanged_s.subscribe {
if (_isCasting) {
setLastPositionMilliseconds((it * 1000.0).toLong(), true);
_timeBar.setPosition(it);
_timeBar.setBufferedPosition(0);
_timeBar.setDuration(video?.duration ?: 0);
}
}
}
_playerProgress.player = _player.exoPlayer?.player;
@@ -783,6 +790,7 @@ class VideoDetailView : ConstraintLayout {
_lastAudioSource = null;
_lastSubtitleSource = null;
video = null;
_container_content_liveChat?.close();
_player.clear();
cleanupPlaybackTracker();
Logger.i(TAG, "Keep screen on unset onClose")
@@ -1527,60 +1535,68 @@ class VideoDetailView : ConstraintLayout {
_rating.visibility = View.GONE;
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
val queryReferencesResponse = ApiMethods.getQueryReferences(
ApiMethods.SERVER, ref, null, null,
arrayListOf(
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
.setFromType(ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.like.data)
).build(),
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
.setFromType(ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.dislike.data)
).build()
),
extraByteReferences = listOfNotNull(extraBytesRef)
);
if (StatePolycentric.instance.enabled) {
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
val queryReferencesResponse = ApiMethods.getQueryReferences(
ApiMethods.SERVER, ref, null, null,
arrayListOf(
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
.setFromType(ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.like.data)
).build(),
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
.setFromType(ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.dislike.data)
).build()
),
extraByteReferences = listOfNotNull(extraBytesRef)
);
val likes = queryReferencesResponse.countsList[0];
val dislikes = queryReferencesResponse.countsList[1];
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
val likes = queryReferencesResponse.countsList[0];
val dislikes = queryReferencesResponse.countsList[1];
val hasLiked =
StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
val hasDisliked =
StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
withContext(Dispatchers.Main) {
_rating.visibility = View.VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) {
args.processHandle.opinion(ref, Opinion.dislike);
} else {
args.processHandle.opinion(ref, Opinion.neutral);
}
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e)
withContext(Dispatchers.Main) {
_rating.visibility = View.VISIBLE;
_rating.setRating(
RatingLikeDislikes(likes, dislikes),
hasLiked,
hasDisliked
);
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) {
args.processHandle.opinion(ref, Opinion.dislike);
} else {
args.processHandle.opinion(ref, Opinion.neutral);
}
}
StatePolycentric.instance.updateLikeMap(
ref,
args.hasLiked,
args.hasDisliked
)
};
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e)
}
}
StatePolycentric.instance.updateLikeMap(
ref,
args.hasLiked,
args.hasDisliked
)
};
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
_rating.visibility = View.GONE;
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
_rating.visibility = View.GONE;
}
}
@@ -2430,6 +2446,7 @@ class VideoDetailView : ConstraintLayout {
Logger.i(TAG, "handleFullScreen(fullscreen=$fullscreen)")
if(fullscreen) {
_container_content.visibility = GONE
_layoutPlayerContainer.setPadding(0, 0, 0, 0);
val lp = _container_content.layoutParams as LayoutParams;
@@ -2443,6 +2460,7 @@ class VideoDetailView : ConstraintLayout {
setProgressBarOverlayed(null);
}
else {
_container_content.visibility = VISIBLE
_layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt());
val lp = _container_content.layoutParams as LayoutParams;
@@ -2502,6 +2520,13 @@ class VideoDetailView : ConstraintLayout {
}
}
fun saveBrightness() {
_player.gestureControl.saveBrightness()
}
fun restoreBrightness() {
_player.gestureControl.restoreBrightness()
}
fun setFullscreen(fullscreen : Boolean) {
Logger.i(TAG, "setFullscreen(fullscreen=$fullscreen)")
_player.setFullScreen(fullscreen)
@@ -2665,9 +2690,10 @@ class VideoDetailView : ConstraintLayout {
}
onChannelClicked.subscribe {
if(it.url.isNotBlank())
if(it.url.isNotBlank()) {
fragment.minimizeVideoDetail()
fragment.navigate<ChannelFragment>(it)
else
} else
UIDialogs.appToast("No author url present");
}
@@ -3059,7 +3085,12 @@ class VideoDetailView : ConstraintLayout {
Logger.w(TAG, "Failed to load recommendations.", it);
};
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) })
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, {
if (!StatePolycentric.instance.enabled)
return@TaskHandler null
ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!)
})
.success { it -> setPolycentricProfile(it, animate = true) }
.exception<Throwable> {
Logger.w(TAG, "Failed to load claims.", it);
@@ -3131,10 +3162,6 @@ class VideoDetailView : ConstraintLayout {
fun applyFragment(frag: VideoDetailFragment) {
fragment = frag;
fragment.onMinimize.subscribe {
_liveChat?.stop();
_container_content_liveChat.close();
}
}
@@ -224,7 +224,8 @@ abstract class VideoListEditorView : LinearLayout {
fun updateVideoFilters() {
val videos = _loadedVideos ?: return;
_videoListEditorView.setVideos(filterVideos(videos), _loadedVideosCanEdit);
val filteredVideos = filterVideos(videos)
_videoListEditorView.setVideos(filteredVideos, _loadedVideosCanEdit && filteredVideos.size == videos.size);
}
protected fun setButtonDownloadVisible(isVisible: Boolean) {
@@ -0,0 +1,223 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Animatable
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewPropertyAnimator
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.children
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
import com.futo.platformplayer.api.media.platforms.js.models.JSWebDetails
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.fixHtmlWhitespace
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.adapters.ChannelTab
import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView
import com.futo.platformplayer.views.comments.AddCommentView
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.segments.CommentsList
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Models
import com.futo.polycentric.core.Opinion
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import com.google.android.flexbox.FlexboxLayout
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.protobuf.ByteString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import userpackage.Protocol
import java.lang.Integer.min
class WebDetailFragment : MainFragment {
override val isMainView: Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var _viewDetail: WebDetailView? = null;
constructor() : super() { }
override fun onBackPressed(): Boolean {
return false;
}
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = WebDetailView(inflater.context).applyFragment(this);
_viewDetail = view;
return view;
}
override fun onDestroyMainView() {
super.onDestroyMainView();
_viewDetail?.onDestroy();
_viewDetail = null;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
if (parameter is JSWeb) {
_viewDetail?.clear();
_viewDetail?.setWeb(parameter);
}
if (parameter is JSWebDetails) {
_viewDetail?.clear();
_viewDetail?.setWebDetails(parameter);
}
}
private class WebDetailView : ConstraintLayout {
private lateinit var _fragment: WebDetailFragment;
private var _url: String? = null;
private var _isLoading = false;
private var _web: JSWebDetails? = null;
private val _layoutLoadingOverlay: FrameLayout;
private val _imageLoader: ImageView;
private val _webview: WebView;
private val _taskLoadPost = if(!isInEditMode) TaskHandler<String, JSWebDetails>(
StateApp.instance.scopeGetter,
{
val result = StatePlatform.instance.getContentDetails(it).await();
if(result !is JSWebDetails)
throw IllegalStateException(context.getString(R.string.expected_media_content_found) + " ${result.contentType}");
return@TaskHandler result;
})
.success { setWebDetails(it) }
.exception<Throwable> {
Logger.w(ChannelFragment.TAG, context.getString(R.string.failed_to_load_post), it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
constructor(context: Context) : super(context) {
inflate(context, R.layout.fragview_web_detail, this);
val root = findViewById<FrameLayout>(R.id.root);
_layoutLoadingOverlay = findViewById(R.id.layout_loading_overlay);
_imageLoader = findViewById(R.id.image_loader);
_webview = findViewById(R.id.webview);
_webview.webViewClient = object: WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url);
if(url != "about:blank")
setLoading(false);
}
}
}
fun applyFragment(frag: WebDetailFragment): WebDetailView {
_fragment = frag;
return this;
}
fun clear() {
_webview.loadUrl("about:blank");
}
fun setWeb(value: JSWeb) {
_url = value.url;
setLoading(true);
clear();
fetchPost();
}
fun setWebDetails(value: JSWebDetails) {
_web = value;
setLoading(true);
_webview.loadUrl("about:blank");
if(!value.html.isNullOrEmpty())
_webview.loadData(value.html, "text/html", null);
else
_webview.loadUrl(value.url ?: "about:blank");
}
private fun fetchPost() {
Logger.i(WebDetailView.TAG, "fetchWeb")
_web = null;
val url = _url;
if (!url.isNullOrBlank()) {
setLoading(true);
_taskLoadPost.run(url);
}
}
fun onDestroy() {
_webview.loadUrl("about:blank");
}
private fun setLoading(isLoading : Boolean) {
if (_isLoading == isLoading) {
return;
}
_isLoading = isLoading;
if(isLoading) {
(_imageLoader.drawable as Animatable?)?.start()
_layoutLoadingOverlay.visibility = View.VISIBLE;
}
else {
_layoutLoadingOverlay.visibility = View.GONE;
(_imageLoader.drawable as Animatable?)?.stop()
}
}
companion object {
const val TAG = "WebDetailFragment"
}
}
companion object {
fun newInstance() = WebDetailFragment().apply {}
}
}
@@ -88,7 +88,6 @@ class SearchTopBarFragment : TopFragment() {
} else if (parameter is SuggestionsFragmentData) {
this.setText(parameter.query);
_searchType = parameter.searchType;
_channelUrl = parameter.channelUrl;
}
if(currentMain is SuggestionsFragment)
@@ -114,7 +113,7 @@ class SearchTopBarFragment : TopFragment() {
fun clear() {
_editSearch?.text?.clear();
if (currentMain !is SuggestionsFragment) {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", _searchType, _channelUrl), false);
navigate<SuggestionsFragment>(SuggestionsFragmentData("", _searchType), false);
} else {
onSearch.emit("");
}
@@ -1,5 +1,11 @@
package com.futo.platformplayer.parsers
import android.net.Uri
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
@@ -7,12 +13,15 @@ import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudi
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.toYesNo
import com.futo.platformplayer.yesNoToBoolean
import java.io.ByteArrayInputStream
import java.net.URI
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import kotlin.text.ifEmpty
class HLS {
companion object {
@OptIn(UnstableApi::class)
fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist {
val baseUrl = URI(sourceUrl).resolve("./").toString()
@@ -49,6 +58,31 @@ class HLS {
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments)
}
fun mediaRenditionToVariant(rendition: MediaRendition): HLSVariantAudioUrlSource? {
if (rendition.uri == null) {
return null
}
val suffix = listOf(rendition.language, rendition.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
return when (rendition.type) {
"AUDIO" -> HLSVariantAudioUrlSource(rendition.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", rendition.language ?: "", null, false, false, rendition.uri)
else -> null
}
}
fun variantReferenceToVariant(reference: VariantPlaylistReference): HLSVariantVideoUrlSource {
var width: Int? = null
var height: Int? = null
val resolutionTokens = reference.streamInfo.resolution?.split('x')
if (resolutionTokens?.isNotEmpty() == true) {
width = resolutionTokens[0].toIntOrNull()
height = resolutionTokens[1].toIntOrNull()
}
val suffix = listOf(reference.streamInfo.video, reference.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
return HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", reference.streamInfo.codecs ?: "", reference.streamInfo.bandwidth, 0, false, reference.url)
}
fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist {
val lines = content.lines()
val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull()
@@ -61,7 +95,25 @@ class HLS {
val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":")
val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) }
val keyInfo =
lines.find { it.startsWith("#EXT-X-KEY:") }?.substringAfter(":")?.split(",")
val key = keyInfo?.find { it.startsWith("URI=") }?.substringAfter("=")?.trim('"')
val iv =
keyInfo?.find { it.startsWith("IV=") }?.substringAfter("=")?.substringAfter("x")
val decryptionInfo: DecryptionInfo? = key?.let { k ->
DecryptionInfo(k, iv)
}
val initSegment =
lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0)
?.substringAfter("=")?.trim('"')
val segments = mutableListOf<Segment>()
if (initSegment != null) {
segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment)))
}
var currentSegment: MediaSegment? = null
lines.forEach { line ->
when {
@@ -86,7 +138,7 @@ class HLS {
}
}
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments)
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments, decryptionInfo)
}
fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> {
@@ -270,7 +322,7 @@ class HLS {
val name: String?,
val isDefault: Boolean?,
val isAutoSelect: Boolean?,
val isForced: Boolean?
val isForced: Boolean?,
) {
fun toM3U8Line(): String = buildString {
append("#EXT-X-MEDIA:")
@@ -319,30 +371,13 @@ class HLS {
fun getVideoSources(): List<HLSVariantVideoUrlSource> {
return variantPlaylistsRefs.map {
var width: Int? = null
var height: Int? = null
val resolutionTokens = it.streamInfo.resolution?.split('x')
if (resolutionTokens?.isNotEmpty() == true) {
width = resolutionTokens[0].toIntOrNull()
height = resolutionTokens[1].toIntOrNull()
}
val suffix = listOf(it.streamInfo.video, it.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs ?: "", it.streamInfo.bandwidth, 0, false, it.url)
variantReferenceToVariant(it)
}
}
fun getAudioSources(): List<HLSVariantAudioUrlSource> {
return mediaRenditions.mapNotNull {
if (it.uri == null) {
return@mapNotNull null
}
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
return@mapNotNull when (it.type) {
"AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, false, it.uri)
else -> null
}
return@mapNotNull mediaRenditionToVariant(it)
}
}
@@ -368,6 +403,11 @@ class HLS {
}
}
data class DecryptionInfo(
val keyUrl: String,
val iv: String?
)
data class VariantPlaylist(
val version: Int?,
val targetDuration: Int?,
@@ -376,7 +416,8 @@ class HLS {
val programDateTime: ZonedDateTime?,
val playlistType: String?,
val streamInfo: StreamInfo?,
val segments: List<Segment>
val segments: List<Segment>,
val decryptionInfo: DecryptionInfo? = null
) {
fun buildM3U8(): String = buildString {
append("#EXTM3U\n")
@@ -412,24 +412,12 @@ class StateApp {
}
if (Settings.instance.synchronization.enabled) {
StateSync.instance.start(context, {
try {
UIDialogs.toast("Failed to start sync, port in use")
} catch (e: Throwable) {
//Ignored
}
})
StateSync.instance.start(context)
}
settingsActivityClosed.subscribe {
if (Settings.instance.synchronization.enabled) {
StateSync.instance.start(context, {
try {
UIDialogs.toast("Failed to start sync, port in use")
} catch (e: Throwable) {
//Ignored
}
})
StateSync.instance.start(context)
} else {
StateSync.instance.stop()
}
@@ -383,7 +383,7 @@ class StateDownloads {
}
private fun validateDownload(videoState: VideoDownload) {
if(_downloading.hasItem { it.videoEither.url == videoState.videoEither.url })
throw IllegalStateException("Video [${videoState.name}] is already queued for dowload");
throw IllegalStateException("Video [${videoState.name}] is already queued for download");
val existing = getCachedVideo(videoState.id);
if(existing != null) {
@@ -5,7 +5,6 @@ import androidx.collection.LruCache
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.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.PlatformMultiClientPool
@@ -46,7 +45,6 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.views.ToastView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
@@ -56,7 +54,6 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.internal.concat
import java.lang.Thread.sleep
import java.time.OffsetDateTime
import kotlin.streams.asSequence
@@ -94,6 +91,7 @@ class StatePlatform {
private val _trackerClientPool = PlatformMultiClientPool("Trackers", 1); //Used exclusively for playback trackers
private val _liveEventClientPool = PlatformMultiClientPool("LiveEvents", 1); //Used exclusively for live events
private val _privateClientPool = PlatformMultiClientPool("Private", 2, true); //Used primarily for calls if in incognito mode
private val _instantClientPool = PlatformMultiClientPool("Instant", 1, false, true); //Used for all instant calls
private val _icons : HashMap<String, ImageVariable> = HashMap();
@@ -114,14 +112,14 @@ class StatePlatform {
Logger.i(StatePlatform::class.java.name, "Fetching video details [${url}]");
if(!StateApp.instance.privateMode) {
_enabledClients.find { it.isContentDetailsUrl(url) }?.let {
_enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let {
_mainClientPool.getClientPooled(it).getContentDetails(url)
}
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
}
else {
Logger.i(TAG, "Fetching details with private client");
_enabledClients.find { it.isContentDetailsUrl(url) }?.let {
_enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let {
_privateClientPool.getClientPooled(it).getContentDetails(url)
}
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
@@ -668,10 +666,10 @@ class StatePlatform {
//Video
fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { it.isContentDetailsUrl(url) };
fun hasEnabledContentClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) };
fun getContentClient(url: String) : IPlatformClient = getContentClientOrNull(url)
?: throw NoPlatformClientException("No client enabled that supports this content url (${url})");
fun getContentClientOrNull(url: String) : IPlatformClient? = getEnabledClients().find { it.isContentDetailsUrl(url) };
fun getContentClientOrNull(url: String) : IPlatformClient? = getEnabledClients().find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) };
fun getContentDetails(url: String, forceRefetch: Boolean = false): Deferred<IPlatformContentDetails> {
Logger.i(TAG, "Platform - getContentDetails (${url})");
if(forceRefetch)
@@ -712,14 +710,14 @@ class StatePlatform {
return client.getContentRecommendations(url);
}
fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { it.isChannelUrl(url) };
fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isChannelUrl(url) };
fun getChannelClient(url : String, exclude: List<String>? = null) : IPlatformClient = getChannelClientOrNull(url, exclude)
?: throw NoPlatformClientException("No client enabled that supports this channel url (${url})");
fun getChannelClientOrNull(url : String, exclude: List<String>? = null) : IPlatformClient? =
if(exclude == null)
getEnabledClients().find { it.isChannelUrl(url) }
getEnabledClients().find { _instantClientPool.getClientPooled(it).isChannelUrl(url) }
else
getEnabledClients().find { !exclude.contains(it.id) && it.isChannelUrl(url) };
getEnabledClients().find { !exclude.contains(it.id) && _instantClientPool.getClientPooled(it).isChannelUrl(url) };
fun getChannel(url: String, updateSubscriptions: Boolean = true): Deferred<IPlatformChannel> {
Logger.i(TAG, "Platform - getChannel");
@@ -731,7 +729,7 @@ class StatePlatform {
}
}
fun getChannelContent(baseClient: IPlatformClient, channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0): IPager<IPlatformContent> {
fun getChannelContent(baseClient: IPlatformClient, channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, type: String? = null): IPager<IPlatformContent> {
val clientCapabilities = baseClient.getChannelCapabilities();
val client = if(usePooledClients > 1)
_channelClientPool.getClientPooled(baseClient, usePooledClients);
@@ -740,66 +738,75 @@ class StatePlatform {
var lastStream: OffsetDateTime? = null;
val pagerResult: IPager<IPlatformContent>;
if(!clientCapabilities.hasType(ResultCapabilities.TYPE_MIXED) &&
( clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS) ||
clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS) ||
clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE) ||
clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS)
)) {
val toQuery = mutableListOf<String>();
if(clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
toQuery.add(ResultCapabilities.TYPE_VIDEOS);
if(clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS))
toQuery.add(ResultCapabilities.TYPE_STREAMS);
if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE))
toQuery.add(ResultCapabilities.TYPE_LIVE);
if(clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS))
toQuery.add(ResultCapabilities.TYPE_POSTS);
if (type == null) {
if(!clientCapabilities.hasType(ResultCapabilities.TYPE_MIXED) &&
( clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS) ||
clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS) ||
clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE) ||
clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS)
)) {
val toQuery = mutableListOf<String>();
if(clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
toQuery.add(ResultCapabilities.TYPE_VIDEOS);
if(clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS))
toQuery.add(ResultCapabilities.TYPE_STREAMS);
if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE))
toQuery.add(ResultCapabilities.TYPE_LIVE);
if(clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS))
toQuery.add(ResultCapabilities.TYPE_POSTS);
if(isSubscriptionOptimized) {
val sub = StateSubscriptions.instance.getSubscription(channelUrl);
if(sub != null) {
if(!sub.shouldFetchStreams()) {
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 7 days, skipping live streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
toQuery.remove(ResultCapabilities.TYPE_LIVE);
}
if(!sub.shouldFetchLiveStreams()) {
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
toQuery.remove(ResultCapabilities.TYPE_STREAMS);
}
if(!sub.shouldFetchPosts()) {
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 5 days, skipping posts [${sub.lastPost.getNowDiffDays()} days ago]");
toQuery.remove(ResultCapabilities.TYPE_POSTS);
}
}
}
//Merged pager
val pagers = toQuery
.parallelStream()
.map {
val results = client.getChannelContents(channelUrl, it, ResultCapabilities.ORDER_CHONOLOGICAL) ;
when(it) {
ResultCapabilities.TYPE_STREAMS -> {
val streamResults = results.getResults();
if(streamResults.size == 0)
lastStream = OffsetDateTime.MIN;
else
lastStream = results.getResults().firstOrNull()?.datetime;
if(isSubscriptionOptimized) {
val sub = StateSubscriptions.instance.getSubscription(channelUrl);
if(sub != null) {
if(!sub.shouldFetchStreams()) {
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 7 days, skipping live streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
toQuery.remove(ResultCapabilities.TYPE_LIVE);
}
if(!sub.shouldFetchLiveStreams()) {
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
toQuery.remove(ResultCapabilities.TYPE_STREAMS);
}
if(!sub.shouldFetchPosts()) {
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 5 days, skipping posts [${sub.lastPost.getNowDiffDays()} days ago]");
toQuery.remove(ResultCapabilities.TYPE_POSTS);
}
}
return@map results;
}
.asSequence()
.toList();
val pager = MultiChronoContentPager(pagers.toTypedArray());
pager.initialize();
pagerResult = pager;
//Merged pager
val pagers = toQuery
.parallelStream()
.map {
val results = client.getChannelContents(channelUrl, it, ResultCapabilities.ORDER_CHONOLOGICAL) ;
when(it) {
ResultCapabilities.TYPE_STREAMS -> {
val streamResults = results.getResults();
if(streamResults.size == 0)
lastStream = OffsetDateTime.MIN;
else
lastStream = results.getResults().firstOrNull()?.datetime;
}
}
return@map results;
}
.asSequence()
.toList();
val pager = MultiChronoContentPager(pagers.toTypedArray());
pager.initialize();
pagerResult = pager;
}
else {
pagerResult = client.getChannelContents(channelUrl, ResultCapabilities.TYPE_MIXED, ResultCapabilities.ORDER_CHONOLOGICAL);
}
} else {
pagerResult = if (type == ResultCapabilities.TYPE_SHORTS && clientCapabilities.hasType(ResultCapabilities.TYPE_SHORTS)) {
client.getChannelContents(channelUrl, ResultCapabilities.TYPE_SHORTS, ResultCapabilities.ORDER_CHONOLOGICAL);
} else {
EmptyPager()
}
}
else
pagerResult = client.getChannelContents(channelUrl, ResultCapabilities.TYPE_MIXED, ResultCapabilities.ORDER_CHONOLOGICAL);
//Subscription optimization
val sub = StateSubscriptions.instance.getSubscription(channelUrl);
@@ -851,10 +858,10 @@ class StatePlatform {
return pagerResult;
}
fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List<String>? = null): IPager<IPlatformContent> {
fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List<String>? = null, type: String? = null): IPager<IPlatformContent> {
Logger.i(TAG, "Platform - getChannelVideos");
val baseClient = getChannelClient(channelUrl, ignorePlugins);
return getChannelContent(baseClient, channelUrl, isSubscriptionOptimized, usePooledClients);
return getChannelContent(baseClient, channelUrl, isSubscriptionOptimized, usePooledClients, type);
}
fun getChannelContent(channelUrl: String, type: String?, ordering: String = ResultCapabilities.ORDER_CHONOLOGICAL): IPager<IPlatformContent> {
val client = getChannelClient(channelUrl);
@@ -906,9 +913,9 @@ class StatePlatform {
return urls;
}
fun hasEnabledPlaylistClient(url: String) : Boolean = getEnabledClients().any { it.isPlaylistUrl(url) };
fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { it.isPlaylistUrl(url) }
fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { it.isPlaylistUrl(url) }
fun hasEnabledPlaylistClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) };
fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) }
fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) }
?: throw NoPlatformClientException("No client enabled that supports this playlist url (${url})");
fun getPlaylist(url: String): IPlatformPlaylistDetails {
return getPlaylistClient(url).getPlaylist(url);
@@ -598,7 +598,7 @@ class StatePlayer {
}
if(_queuePosition < _queue.size) {
return _queue[_queuePosition];
return getCurrentQueueItem();
}
}
return null;
@@ -236,7 +236,7 @@ class StatePolycentric {
return Pair(didUpdate, listOf(url));
}
fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager<IPlatformContent>? {
fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1, type: String? = null): IPager<IPlatformContent>? {
ensureEnabled()
//TODO: Currently abusing subscription concurrency for parallelism
@@ -248,7 +248,11 @@ class StatePolycentric {
return@mapNotNull Pair(client, scope.async(Dispatchers.IO) {
try {
return@async StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency);
if (type == null) {
return@async StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency);
} else {
return@async StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency, type = type);
}
} catch (ex: Throwable) {
Logger.e(TAG, "getChannelContent", ex);
return@async null;
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
package com.futo.platformplayer.sync.internal
import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.noise.protocol.CipherStatePair
import com.futo.platformplayer.noise.protocol.DHState
@@ -54,6 +55,7 @@ class ChannelSocket(private val session: SyncSocketSession) : IChannel {
}
override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, contentEncoding: ContentEncoding?) {
ensureNotMainThread()
if (data != null) {
session.send(opcode, subOpcode, data, contentEncoding)
} else {
@@ -71,12 +73,12 @@ class ChannelRelayed(
private val sendLock = Object()
private val decryptLock = Object()
private var handshakeState: HandshakeState? = if (initiator) {
HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR).apply {
HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR).apply {
localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair)
remotePublicKey.setPublicKey(Base64.getDecoder().decode(publicKey), 0)
}
} else {
HandshakeState(StateSync.protocolName, HandshakeState.RESPONDER).apply {
HandshakeState(SyncService.protocolName, HandshakeState.RESPONDER).apply {
localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair)
}
}
@@ -94,11 +96,39 @@ class ChannelRelayed(
private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null
private var onClose: ((IChannel) -> Unit)? = null
private var disposed = false
private var _lastPongTime: Long = 0
private val _pingInterval: Long = 5000 // 5 seconds in milliseconds
private val _disconnectTimeout: Long = 30000 // 30 seconds in milliseconds
init {
handshakeState?.start()
}
private fun startPingLoop() {
if (remoteVersion!! < 5) {
return
}
_lastPongTime = System.currentTimeMillis()
Thread {
try {
while (!disposed) {
Thread.sleep(_pingInterval)
if (System.currentTimeMillis() - _lastPongTime > _disconnectTimeout) {
Logger.e("ChannelRelayed", "Channel timed out waiting for PONG; closing.")
close()
break
}
send(Opcode.PING.value, 0u)
}
} catch (e: Exception) {
Logger.e("ChannelRelayed", "Ping loop failed", e)
close()
}
}.start()
}
override fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) {
this.onData = onData
}
@@ -134,6 +164,10 @@ class ChannelRelayed(
}
fun invokeDataHandler(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
if (opcode == Opcode.PONG.value) {
_lastPongTime = System.currentTimeMillis()
return
}
onData?.invoke(session, this, opcode, subOpcode, data)
}
@@ -148,10 +182,12 @@ class ChannelRelayed(
handshakeState = null
this.transport = transport
Logger.i("ChannelRelayed", "Completed handshake for connectionId $connectionId")
startPingLoop()
}
private fun sendPacket(packet: ByteArray) {
throwIfDisposed()
ensureNotMainThread()
synchronized(sendLock) {
val encryptedPayload = ByteArray(packet.size + 16)
@@ -169,6 +205,7 @@ class ChannelRelayed(
fun sendError(errorCode: SyncErrorCode) {
throwIfDisposed()
ensureNotMainThread()
synchronized(sendLock) {
val packet = ByteArray(4)
@@ -189,6 +226,7 @@ class ChannelRelayed(
override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, ce: ContentEncoding?) {
throwIfDisposed()
ensureNotMainThread()
var contentEncoding: ContentEncoding? = ce
var processedData = data
@@ -272,6 +310,7 @@ class ChannelRelayed(
fun sendRequestTransport(requestId: Int, publicKey: String, appId: UInt, pairingCode: String? = null) {
throwIfDisposed()
ensureNotMainThread()
synchronized(sendLock) {
val channelMessage = ByteArray(1024)
@@ -312,6 +351,7 @@ class ChannelRelayed(
fun sendResponseTransport(remoteVersion: Int, requestId: Int, handshakeMessage: ByteArray) {
throwIfDisposed()
ensureNotMainThread()
synchronized(sendLock) {
val message = ByteArray(1024)
@@ -0,0 +1,825 @@
package com.futo.platformplayer.sync.internal
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.util.Log
import com.futo.platformplayer.Settings
import com.futo.platformplayer.generateReadablePassword
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.noise.protocol.DHState
import com.futo.platformplayer.noise.protocol.Noise
import com.futo.platformplayer.states.StateSync
import com.futo.polycentric.core.base64ToByteArray
import com.futo.polycentric.core.toBase64
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.nio.ByteBuffer
import java.util.Base64
import java.util.Locale
import kotlin.math.min
public data class SyncServiceSettings(
val listenerPort: Int = 12315,
val mdnsBroadcast: Boolean = true,
val mdnsConnectDiscovered: Boolean = true,
val bindListener: Boolean = true,
val connectLastKnown: Boolean = true,
val relayHandshakeAllowed: Boolean = true,
val relayPairAllowed: Boolean = true,
val relayEnabled: Boolean = true,
val relayConnectDirect: Boolean = true,
val relayConnectRelayed: Boolean = true
)
interface ISyncDatabaseProvider {
fun isAuthorized(publicKey: String): Boolean
fun addAuthorizedDevice(publicKey: String)
fun removeAuthorizedDevice(publicKey: String)
fun getAllAuthorizedDevices(): Array<String>?
fun getAuthorizedDeviceCount(): Int
fun getSyncKeyPair(): SyncKeyPair?
fun setSyncKeyPair(value: SyncKeyPair)
fun getLastAddress(publicKey: String): String?
fun setLastAddress(publicKey: String, address: String)
fun getDeviceName(publicKey: String): String?
fun setDeviceName(publicKey: String, name: String)
}
class SyncService(
private val serviceName: String,
private val relayServer: String,
private val relayPublicKey: String,
private val appId: UInt,
private val database: ISyncDatabaseProvider,
private val settings: SyncServiceSettings = SyncServiceSettings()
) {
private var _serverSocket: ServerSocket? = null
private var _thread: Thread? = null
private var _connectThread: Thread? = null
private var _mdnsThread: Thread? = null
@Volatile private var _started = false
private val _sessions: MutableMap<String, SyncSession> = mutableMapOf()
private val _lastConnectTimesMdns: MutableMap<String, Long> = mutableMapOf()
private val _lastConnectTimesIp: MutableMap<String, Long> = mutableMapOf()
var serverSocketFailedToStart = false
var serverSocketStarted = false
var relayConnected = false
//TODO: Should sync mdns and casting mdns be merged?
//TODO: Decrease interval that devices are updated
//TODO: Send less data
private val _pairingCode: String? = generateReadablePassword(8)
val pairingCode: String? get() = _pairingCode
private var _relaySession: SyncSocketSession? = null
private var _threadRelay: Thread? = null
private val _remotePendingStatusUpdate = mutableMapOf<String, (complete: Boolean?, message: String) -> Unit>()
private var _nsdManager: NsdManager? = null
private var _scope: CoroutineScope? = null
private val _mdnsCache = mutableMapOf<String, SyncDeviceInfo>()
private var _discoveryListener: NsdManager.DiscoveryListener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(regType: String) {
Log.d(TAG, "Service discovery started for $regType")
}
override fun onDiscoveryStopped(serviceType: String) {
Log.i(TAG, "Discovery stopped: $serviceType")
}
override fun onServiceLost(service: NsdServiceInfo) {
Log.e(TAG, "service lost: $service")
val urlSafePkey = service.attributes["pk"]?.decodeToString() ?: return
val pkey = Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')).toBase64()
synchronized(_mdnsCache) {
_mdnsCache.remove(pkey)
}
}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
fun addOrUpdate(name: String, adrs: Array<InetAddress>, port: Int, attributes: Map<String, ByteArray>) {
if (!Settings.instance.synchronization.connectDiscovered) {
return
}
val urlSafePkey = attributes.get("pk")?.decodeToString() ?: return
val pkey = Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')).toBase64()
val syncDeviceInfo = SyncDeviceInfo(pkey, adrs.map { it.hostAddress }.toTypedArray(), port, null)
synchronized(_mdnsCache) {
_mdnsCache[pkey] = syncDeviceInfo
}
}
override fun onServiceFound(service: NsdServiceInfo) {
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
addOrUpdate(service.serviceName, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
service.hostAddresses.toTypedArray()
} else {
if(service.host != null)
arrayOf(service.host);
else
arrayOf();
}, service.port, service.attributes)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
_nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback {
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "onServiceUpdated: $serviceInfo")
addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port, serviceInfo.attributes)
}
override fun onServiceLost() {
Log.v(TAG, "onServiceLost: $service")
val urlSafePkey = service.attributes["pk"]?.decodeToString() ?: return
val pkey = Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')).toBase64()
synchronized(_mdnsCache) {
_mdnsCache.remove(pkey)
}
}
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
}
override fun onServiceInfoCallbackUnregistered() {
Log.v(TAG, "onServiceInfoCallbackUnregistered")
}
})
} else {
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
Log.v(TAG, "Resolve failed: $errorCode")
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port, serviceInfo.attributes)
}
})
}
}
}
private val _registrationListener = object : NsdManager.RegistrationListener {
override fun onServiceRegistered(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "onServiceRegistered: ${serviceInfo.serviceName}")
}
override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
Log.v(TAG, "onRegistrationFailed: ${serviceInfo.serviceName} (error code: $errorCode)")
}
override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "onServiceUnregistered: ${serviceInfo.serviceName}")
}
override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
Log.v(TAG, "onUnregistrationFailed: ${serviceInfo.serviceName} (error code: $errorCode)")
}
}
var keyPair: DHState? = null
var publicKey: String? = null
var onAuthorized: ((SyncSession, Boolean, Boolean) -> Unit)? = null
var onUnauthorized: ((SyncSession) -> Unit)? = null
var onConnectedChanged: ((SyncSession, Boolean) -> Unit)? = null
var onClose: ((SyncSession) -> Unit)? = null
var onData: ((SyncSession, UByte, UByte, ByteBuffer) -> Unit)? = null
var authorizePrompt: ((String, (Boolean) -> Unit) -> Unit)? = null
fun start(context: Context) {
if (_started) {
Logger.i(TAG, "Already started.")
return
}
_started = true
_scope = CoroutineScope(Dispatchers.IO)
try {
val syncKeyPair = database.getSyncKeyPair() ?: throw Exception("SyncKeyPair not found")
val p = Noise.createDH(dh)
p.setPublicKey(syncKeyPair.publicKey.base64ToByteArray(), 0)
p.setPrivateKey(syncKeyPair.privateKey.base64ToByteArray(), 0)
keyPair = p
} catch (e: Throwable) {
//Sync key pair non-existing, invalid or lost
val p = Noise.createDH(dh)
p.generateKeyPair()
val publicKey = ByteArray(p.publicKeyLength)
p.getPublicKey(publicKey, 0)
val privateKey = ByteArray(p.privateKeyLength)
p.getPrivateKey(privateKey, 0)
val syncKeyPair = SyncKeyPair(1, publicKey.toBase64(), privateKey.toBase64())
database.setSyncKeyPair(syncKeyPair)
Logger.e(TAG, "Failed to load existing key pair", e)
keyPair = p
}
publicKey = keyPair?.let {
val pkey = ByteArray(it.publicKeyLength)
it.getPublicKey(pkey, 0)
return@let pkey.toBase64()
}
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
if (settings.mdnsConnectDiscovered) {
startMdnsRetryLoop()
}
if (settings.mdnsBroadcast) {
val pk = publicKey
val nsdManager = _nsdManager
if (pk != null && nsdManager != null) {
val sn = serviceName
val serviceInfo = NsdServiceInfo().apply {
serviceName = getDeviceName()
serviceType = sn
port = settings.listenerPort
setAttribute("pk", pk.replace('+', '-').replace('/', '_').replace("=", ""))
}
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, _registrationListener)
}
}
Logger.i(TAG, "Sync key pair initialized (public key = $publicKey)")
serverSocketStarted = false
if (settings.bindListener) {
startListener()
}
relayConnected = false
if (settings.relayEnabled) {
startRelayLoop()
}
if (settings.connectLastKnown) {
startConnectLastLoop()
}
}
private fun startListener() {
serverSocketFailedToStart = false
serverSocketStarted = false
_thread = Thread {
try {
val serverSocket = ServerSocket(settings.listenerPort)
_serverSocket = serverSocket
serverSocketStarted = true
Log.i(TAG, "Running on port ${settings.listenerPort} (TCP)")
while (_started) {
val socket = serverSocket.accept()
val session = createSocketSession(socket, true)
session.startAsResponder()
}
serverSocketStarted = false
} catch (e: Throwable) {
Logger.e(TAG, "Failed to bind server socket to port ${settings.listenerPort}", e)
serverSocketFailedToStart = true
serverSocketStarted = false
}
}.apply { start() }
}
private fun startMdnsRetryLoop() {
_nsdManager?.apply {
discoverServices(serviceName, NsdManager.PROTOCOL_DNS_SD, _discoveryListener)
}
_mdnsThread = Thread {
while (_started) {
try {
val now = System.currentTimeMillis()
synchronized(_mdnsCache) {
for ((pkey, info) in _mdnsCache) {
if (!database.isAuthorized(pkey) || isConnected(pkey)) continue
val last = synchronized(_lastConnectTimesMdns) {
_lastConnectTimesMdns[pkey] ?: 0L
}
if (now - last > 30_000L) {
_lastConnectTimesMdns[pkey] = now
try {
Logger.i(TAG, "MDNS-retry: connecting to $pkey")
connect(info)
} catch (ex: Throwable) {
Logger.w(TAG, "MDNS retry failed for $pkey", ex)
}
}
}
}
} catch (ex: Throwable) {
Logger.e(TAG, "Error in MDNS retry loop", ex)
}
Thread.sleep(5000)
}
}.apply { start() }
}
private fun startConnectLastLoop() {
_connectThread = Thread {
Log.i(TAG, "Running auto reconnector")
while (_started) {
val authorizedDevices = database.getAllAuthorizedDevices() ?: arrayOf()
val addressesToConnect = authorizedDevices.mapNotNull {
val connected = isConnected(it)
if (connected) {
return@mapNotNull null
}
val lastKnownAddress = database.getLastAddress(it) ?: return@mapNotNull null
return@mapNotNull Pair(it, lastKnownAddress)
}
for (connectPair in addressesToConnect) {
try {
val now = System.currentTimeMillis()
val lastConnectTime = synchronized(_lastConnectTimesIp) {
_lastConnectTimesIp[connectPair.first] ?: 0
}
//Connect once every 30 seconds, max
if (now - lastConnectTime > 30000) {
synchronized(_lastConnectTimesIp) {
_lastConnectTimesIp[connectPair.first] = now
}
Logger.i(TAG, "Attempting to connect to authorized device by last known IP '${connectPair.first}' with pkey=${connectPair.first}")
connect(arrayOf(connectPair.second), settings.listenerPort, connectPair.first, null)
}
} catch (e: Throwable) {
Logger.i(TAG, "Failed to connect to " + connectPair.first, e)
}
}
Thread.sleep(5000)
}
}.apply { start() }
}
private fun startRelayLoop() {
relayConnected = false
_threadRelay = Thread {
try {
var backoffs: Array<Long> = arrayOf(1000, 5000, 10000, 20000)
var backoffIndex = 0;
while (_started) {
try {
Log.i(TAG, "Starting relay session...")
relayConnected = false
var socketClosed = false;
val socket = Socket(relayServer, 9000)
_relaySession = SyncSocketSession(
(socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!,
keyPair!!,
socket,
isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId ->
isHandshakeAllowed(
linkType,
syncSocketSession,
publicKey,
pairingCode,
appId
)
},
onNewChannel = { _, c ->
val remotePublicKey = c.remotePublicKey
if (remotePublicKey == null) {
Log.e(
TAG,
"Remote public key should never be null in onNewChannel."
)
return@SyncSocketSession
}
Log.i(
TAG,
"New channel established from relay (pk: '$remotePublicKey')."
)
var session: SyncSession?
synchronized(_sessions) {
session = _sessions[remotePublicKey]
if (session == null) {
val remoteDeviceName =
database.getDeviceName(remotePublicKey)
session =
createNewSyncSession(remotePublicKey, remoteDeviceName)
_sessions[remotePublicKey] = session!!
}
session!!.addChannel(c)
}
c.setDataHandler { _, channel, opcode, subOpcode, data ->
session?.handlePacket(opcode, subOpcode, data)
}
c.setCloseHandler { channel ->
session?.removeChannel(channel)
}
},
onChannelEstablished = { _, channel, isResponder ->
handleAuthorization(channel, isResponder)
},
onClose = { socketClosed = true },
onHandshakeComplete = { relaySession ->
backoffIndex = 0
Thread {
try {
while (_started && !socketClosed) {
val unconnectedAuthorizedDevices =
database.getAllAuthorizedDevices()
?.filter { !isConnected(it) }?.toTypedArray()
?: arrayOf()
relaySession.publishConnectionInformation(
unconnectedAuthorizedDevices,
settings.listenerPort,
settings.relayConnectDirect,
false,
false,
settings.relayConnectRelayed
)
Logger.v(
TAG,
"Requesting ${unconnectedAuthorizedDevices.size} devices connection information"
)
val connectionInfos = runBlocking {
relaySession.requestBulkConnectionInfo(
unconnectedAuthorizedDevices
)
}
Logger.v(
TAG,
"Received ${connectionInfos.size} devices connection information"
)
for ((targetKey, connectionInfo) in connectionInfos) {
val potentialLocalAddresses =
connectionInfo.ipv4Addresses
.filter { it != connectionInfo.remoteIp }
if (connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) {
Thread {
try {
Log.v(
TAG,
"Attempting to connect directly, locally to '$targetKey'."
)
connect(
potentialLocalAddresses.map { it }
.toTypedArray(),
settings.listenerPort,
targetKey,
null
)
} catch (e: Throwable) {
Log.e(
TAG,
"Failed to start direct connection using connection info with $targetKey.",
e
)
}
}.start()
}
if (connectionInfo.allowRemoteDirect) {
// TODO: Implement direct remote connection if needed
}
if (connectionInfo.allowRemoteHolePunched) {
// TODO: Implement hole punching if needed
}
if (connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) {
try {
Logger.v(
TAG,
"Attempting relayed connection with '$targetKey'."
)
runBlocking {
relaySession.startRelayedChannel(
targetKey,
appId,
null
)
}
} catch (e: Throwable) {
Logger.e(
TAG,
"Failed to start relayed channel with $targetKey.",
e
)
}
}
}
Thread.sleep(15000)
}
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception in relay session.", e)
relaySession.stop()
}
}.start()
}
)
_relaySession!!.authorizable = object : IAuthorizable {
override val isAuthorized: Boolean get() = true
}
relayConnected = true
_relaySession!!.runAsInitiator(relayPublicKey, appId, null)
Log.i(TAG, "Started relay session.")
} catch (e: Throwable) {
Log.e(TAG, "Relay session failed.", e)
} finally {
relayConnected = false
_relaySession?.stop()
_relaySession = null
Thread.sleep(backoffs[min(backoffs.size - 1, backoffIndex++)])
}
}
} catch (ex: Throwable) {
Log.i(TAG, "Unhandled exception in relay loop.", ex)
}
}.apply { start() }
}
private fun createSocketSession(socket: Socket, isResponder: Boolean): SyncSocketSession {
var session: SyncSession? = null
var channelSocket: ChannelSocket? = null
return SyncSocketSession(
(socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!,
keyPair!!,
socket,
onClose = { s ->
if (channelSocket != null)
session?.removeChannel(channelSocket!!)
},
isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode, appId) },
onHandshakeComplete = { s ->
val remotePublicKey = s.remotePublicKey
if (remotePublicKey == null) {
s.stop()
return@SyncSocketSession
}
Logger.i(TAG, "Handshake complete with (LocalPublicKey = ${s.localPublicKey}, RemotePublicKey = ${s.remotePublicKey})")
channelSocket = ChannelSocket(s)
synchronized(_sessions) {
session = _sessions[s.remotePublicKey]
if (session == null) {
val remoteDeviceName = database.getDeviceName(remotePublicKey)
database.setLastAddress(remotePublicKey, s.remoteAddress)
session = createNewSyncSession(remotePublicKey, remoteDeviceName)
_sessions[remotePublicKey] = session!!
}
session!!.addChannel(channelSocket!!)
}
handleAuthorization(channelSocket!!, isResponder)
},
onData = { s, opcode, subOpcode, data ->
session?.handlePacket(opcode, subOpcode, data)
}
)
}
private fun handleAuthorization(channel: IChannel, isResponder: Boolean) {
val syncSession = channel.syncSession!!
val remotePublicKey = channel.remotePublicKey!!
if (isResponder) {
val isAuthorized = database.isAuthorized(remotePublicKey)
if (!isAuthorized) {
val ap = this.authorizePrompt
if (ap == null) {
try {
Logger.i(TAG, "$remotePublicKey unauthorized because AuthorizePrompt is null")
syncSession.unauthorize()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to send authorize result.", e)
}
return;
}
ap.invoke(remotePublicKey) {
try {
_scope?.launch(Dispatchers.IO) {
if (it) {
Logger.i(TAG, "$remotePublicKey manually authorized")
syncSession.authorize()
} else {
Logger.i(TAG, "$remotePublicKey manually unauthorized")
syncSession.unauthorize()
syncSession.close()
}
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to send authorize result.")
}
}
} else {
//Responder does not need to check because already approved
syncSession.authorize()
Logger.i(TAG, "Connection authorized for $remotePublicKey because already authorized")
}
} else {
//Initiator does not need to check because the manual action of scanning the QR counts as approval
syncSession.authorize()
Logger.i(TAG, "Connection authorized for $remotePublicKey because initiator")
}
}
private fun isHandshakeAllowed(linkType: LinkType, syncSocketSession: SyncSocketSession, publicKey: String, pairingCode: String?, appId: UInt): Boolean {
Log.v(TAG, "Check if handshake allowed from '$publicKey' (app id: $appId).")
if (publicKey == StateSync.RELAY_PUBLIC_KEY)
return true
if (database.isAuthorized(publicKey)) {
if (linkType == LinkType.Relayed && !settings.relayHandshakeAllowed)
return false
return true
}
Log.v(TAG, "Check if handshake allowed with pairing code '$pairingCode' with active pairing code '$_pairingCode' (app id: $appId).")
if (_pairingCode == null || pairingCode.isNullOrEmpty())
return false
if (linkType == LinkType.Relayed && !settings.relayPairAllowed)
return false
return _pairingCode == pairingCode
}
private fun createNewSyncSession(rpk: String, remoteDeviceName: String?): SyncSession {
val remotePublicKey = rpk.base64ToByteArray().toBase64()
return SyncSession(
remotePublicKey,
onAuthorized = { it, isNewlyAuthorized, isNewSession ->
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(true, "Authorized")
}
if (isNewSession) {
it.remoteDeviceName?.let { remoteDeviceName ->
database.setDeviceName(remotePublicKey, remoteDeviceName)
}
database.addAuthorizedDevice(remotePublicKey)
}
onAuthorized?.invoke(it, isNewlyAuthorized, isNewSession)
},
onUnauthorized = {
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Unauthorized")
}
onUnauthorized?.invoke(it)
},
onConnectedChanged = { it, connected ->
Logger.i(TAG, "$remotePublicKey connected: $connected")
onConnectedChanged?.invoke(it, connected)
},
onClose = {
Logger.i(TAG, "$remotePublicKey closed")
removeSession(it.remotePublicKey)
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Connection closed")
}
onClose?.invoke(it)
},
dataHandler = { it, opcode, subOpcode, data ->
onData?.invoke(it, opcode, subOpcode, data)
},
remoteDeviceName
)
}
fun isConnected(publicKey: String): Boolean = synchronized(_sessions) { _sessions[publicKey]?.connected ?: false }
fun isAuthorized(publicKey: String): Boolean = database.isAuthorized(publicKey)
fun getSession(publicKey: String): SyncSession? = synchronized(_sessions) { _sessions[publicKey] }
fun getSessions(): List<SyncSession> = synchronized(_sessions) { _sessions.values.toList() }
fun removeSession(publicKey: String) = synchronized(_sessions) { _sessions.remove(publicKey) }
fun getCachedName(publicKey: String): String? = database.getDeviceName(publicKey)
fun getAuthorizedDeviceCount(): Int = database.getAuthorizedDeviceCount()
fun getAllAuthorizedDevices(): Array<String>? = database.getAllAuthorizedDevices()
fun removeAuthorizedDevice(publicKey: String) = database.removeAuthorizedDevice(publicKey)
fun connect(deviceInfo: SyncDeviceInfo, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null) {
try {
connect(deviceInfo.addresses, deviceInfo.port, deviceInfo.publicKey, deviceInfo.pairingCode, onStatusUpdate)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to connect directly", e)
val relaySession = _relaySession
if (relaySession != null && Settings.instance.synchronization.pairThroughRelay) {
onStatusUpdate?.invoke(null, "Connecting via relay...")
runBlocking {
if (onStatusUpdate != null) {
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate[deviceInfo.publicKey.base64ToByteArray().toBase64()] = onStatusUpdate
}
}
relaySession.startRelayedChannel(deviceInfo.publicKey.base64ToByteArray().toBase64(), appId, deviceInfo.pairingCode)
}
} else {
throw e
}
}
}
fun connect(addresses: Array<String>, port: Int, publicKey: String, pairingCode: String?, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null): SyncSocketSession {
onStatusUpdate?.invoke(null, "Connecting directly...")
val socket = getConnectedSocket(addresses.map { InetAddress.getByName(it) }, port) ?: throw Exception("Failed to connect")
onStatusUpdate?.invoke(null, "Handshaking...")
val session = createSocketSession(socket, false)
if (onStatusUpdate != null) {
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate[publicKey.base64ToByteArray().toBase64()] = onStatusUpdate
}
}
session.startAsInitiator(publicKey, appId, pairingCode)
return session
}
fun stop() {
_scope?.cancel()
_scope = null
_relaySession?.stop()
_relaySession = null
_serverSocket?.close()
_serverSocket = null
synchronized(_sessions) {
_sessions.values.forEach { it.close() }
_sessions.clear()
}
}
private fun getDeviceName(): String {
val manufacturer = Build.MANUFACTURER.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
val model = Build.MODEL
return if (model.startsWith(manufacturer, ignoreCase = true)) {
model.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
} else {
"$manufacturer $model".replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
}
}
companion object {
val dh = "25519"
val pattern = "IK"
val cipher = "ChaChaPoly"
val hash = "BLAKE2b"
var protocolName = "Noise_${pattern}_${dh}_${cipher}_${hash}"
private const val TAG = "SyncService"
}
}
@@ -1,6 +1,7 @@
package com.futo.platformplayer.sync.internal
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.states.StateSubscriptions
@@ -16,6 +17,8 @@ interface IAuthorizable {
class SyncSession : IAuthorizable {
private val _channels: MutableList<IChannel> = mutableListOf()
@Volatile
private var _snapshot: Array<IChannel> = emptyArray()
private var _authorized: Boolean = false
private var _remoteAuthorized: Boolean = false
private val _onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit
@@ -82,6 +85,8 @@ class SyncSession : IAuthorizable {
synchronized(_channels) {
_channels.add(channel)
_channels.sortBy { it.linkType.ordinal }
_snapshot = _channels.toTypedArray()
connected = _channels.isNotEmpty()
}
@@ -123,15 +128,20 @@ class SyncSession : IAuthorizable {
fun removeChannel(channel: IChannel) {
synchronized(_channels) {
_channels.remove(channel)
_snapshot = _channels.toTypedArray()
connected = _channels.isNotEmpty()
}
}
fun close() {
synchronized(_channels) {
_channels.toTypedArray()
}.forEach { it.close() }
val toClose = synchronized(_channels) {
val arr = _channels.toTypedArray()
_channels.clear()
_snapshot = emptyArray()
connected = false
arr
}
toClose.forEach { it.close() }
_onClose(this)
}
@@ -192,22 +202,25 @@ class SyncSession : IAuthorizable {
}
inline fun <reified T> sendJsonData(subOpcode: UByte, data: T) {
ensureNotMainThread()
send(Opcode.DATA.value, subOpcode, Json.encodeToString(data))
}
fun sendData(subOpcode: UByte, data: String) {
ensureNotMainThread()
send(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip)
}
fun send(opcode: UByte, subOpcode: UByte, data: String) {
ensureNotMainThread()
send(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip)
}
fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer? = null, contentEncoding: ContentEncoding? = null) {
val channels = synchronized(_channels) { _channels.sortedBy { it.linkType.ordinal }.toList() }
ensureNotMainThread()
val channels = _snapshot
if (channels.isEmpty()) {
//TODO: Should this throw?
Logger.v(TAG, "Packet was not sent (opcode = $opcode, subOpcode = $subOpcode) due to no connected sockets")
Logger.v(TAG, "Packet was not sent … no connected sockets")
return
}
@@ -1,19 +1,19 @@
package com.futo.platformplayer.sync.internal
import android.os.Build
import com.futo.platformplayer.LittleEndianDataInputStream
import com.futo.platformplayer.LittleEndianDataOutputStream
import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.noise.protocol.CipherStatePair
import com.futo.platformplayer.noise.protocol.DHState
import com.futo.platformplayer.noise.protocol.HandshakeState
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.sync.internal.ChannelRelayed.Companion
import com.futo.polycentric.core.base64ToByteArray
import com.futo.polycentric.core.toBase64
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
@@ -30,9 +30,7 @@ import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
import kotlin.math.min
import kotlin.system.measureTimeMillis
import kotlin.time.measureTime
class SyncSocketSession {
private val _socket: Socket
@@ -80,6 +78,11 @@ class SyncSocketSession {
private val _pendingBulkGetRecordRequests = ConcurrentHashMap<Int, CompletableDeferred<Map<String, Pair<ByteArray, Long>>>>()
private val _pendingBulkConnectionInfoRequests = ConcurrentHashMap<Int, CompletableDeferred<Map<String, ConnectionInfo>>>()
@Volatile
private var _lastPongTime: Long = System.currentTimeMillis()
private val _pingInterval: Long = 5000 // 5 seconds in milliseconds
private val _disconnectTimeout: Long = 30000 // 30 seconds in milliseconds
data class ConnectionInfo(
val port: UShort,
val name: String,
@@ -129,6 +132,7 @@ class SyncSocketSession {
try {
handshakeAsInitiator(remotePublicKey, appId, pairingCode)
_onHandshakeComplete?.invoke(this)
startPingLoop()
receiveLoop()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to run as initiator", e)
@@ -143,6 +147,7 @@ class SyncSocketSession {
try {
handshakeAsInitiator(remotePublicKey, appId, pairingCode)
_onHandshakeComplete?.invoke(this)
startPingLoop()
receiveLoop()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to run as initiator", e)
@@ -157,6 +162,7 @@ class SyncSocketSession {
try {
if (handshakeAsResponder()) {
_onHandshakeComplete?.invoke(this)
startPingLoop()
receiveLoop()
}
} catch (e: Throwable) {
@@ -245,7 +251,7 @@ class SyncSocketSession {
private fun handshakeAsInitiator(remotePublicKey: String, appId: UInt, pairingCode: String?) {
performVersionCheck()
val initiator = HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR)
val initiator = HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR)
initiator.localKeyPair.copyFrom(_localKeyPair)
initiator.remotePublicKey.setPublicKey(Base64.getDecoder().decode(remotePublicKey), 0)
initiator.start()
@@ -299,7 +305,7 @@ class SyncSocketSession {
private fun handshakeAsResponder(): Boolean {
performVersionCheck()
val responder = HandshakeState(StateSync.protocolName, HandshakeState.RESPONDER)
val responder = HandshakeState(SyncService.protocolName, HandshakeState.RESPONDER)
responder.localKeyPair.copyFrom(_localKeyPair)
responder.start()
@@ -352,7 +358,7 @@ class SyncSocketSession {
}
private fun performVersionCheck() {
val CURRENT_VERSION = 4
val CURRENT_VERSION = 5
val MINIMUM_VERSION = 4
val versionBytes = ByteArray(4)
@@ -523,7 +529,7 @@ class SyncSocketSession {
val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode, appId) ?: true)
if (!isAllowed) {
val rp = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN)
rp.putInt(2) // Status code for not allowed
rp.putInt(7) // Status code for not allowed
rp.putLong(connectionId)
rp.putInt(requestId)
rp.rewind()
@@ -833,6 +839,30 @@ class SyncSocketSession {
}
}
private fun startPingLoop() {
if (remoteVersion < 5) return
_lastPongTime = System.currentTimeMillis()
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
while (_started) {
delay(_pingInterval)
if (System.currentTimeMillis() - _lastPongTime > _disconnectTimeout) {
Logger.e(TAG, "Session timed out waiting for PONG; closing.")
stop()
break
}
send(Opcode.PING.value)
}
} catch (e: Exception) {
Logger.e(TAG, "Ping loop failed", e)
stop()
}
}
}
private fun handlePacket(opcode: UByte, subOpcode: UByte, d: ByteBuffer, contentEncoding: UByte, sourceChannel: ChannelRelayed?) {
Logger.i(TAG, "Handle packet (opcode = ${opcode}, subOpcode = ${subOpcode})")
@@ -864,6 +894,11 @@ class SyncSocketSession {
return
}
Opcode.PONG.value -> {
if (sourceChannel != null) {
sourceChannel.invokeDataHandler(opcode, subOpcode, data)
} else {
_lastPongTime = System.currentTimeMillis()
}
Logger.v(TAG, "Received pong")
return
}
@@ -3,6 +3,8 @@ package com.futo.platformplayer.views
import android.content.Context
import android.text.TextWatcher
import android.util.AttributeSet
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout
import android.widget.ImageButton
@@ -30,9 +32,26 @@ class SearchView : FrameLayout {
textSearch = findViewById(R.id.edit_search)
buttonClear = findViewById(R.id.button_clear_search)
buttonClear.setOnClickListener { textSearch.text = "" };
buttonClear.setOnClickListener {
textSearch.text = ""
textSearch?.clearFocus()
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(textSearch.windowToken, 0)
onSearchChanged.emit("")
onEnter.emit("")
}
textSearch.setOnEditorActionListener { _, i, _ ->
if (i == EditorInfo.IME_ACTION_DONE) {
textSearch?.clearFocus()
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(textSearch.windowToken, 0)
onEnter.emit(textSearch.text.toString())
return@setOnEditorActionListener true
}
return@setOnEditorActionListener false
}
textSearch.addTextChangedListener {
onSearchChanged.emit(it.toString());
buttonClear.visibility = if ((it?.length ?: 0) > 0) View.VISIBLE else View.GONE
onSearchChanged.emit(it.toString())
};
}
}
@@ -126,7 +126,7 @@ open class PlaylistView : LinearLayout {
}
else {
currentPlaylist = null;
_imageThumbnail.setImageResource(0);
_imageThumbnail.setImageDrawable(null);
}
}
@@ -1,6 +1,7 @@
package com.futo.platformplayer.views.adapters
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
@@ -79,6 +79,8 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
return when(contentType) {
ContentType.PLACEHOLDER -> createPlaceholderViewHolder(viewGroup);
ContentType.MEDIA -> createVideoPreviewViewHolder(viewGroup);
ContentType.ARTICLE -> createPostViewHolder(viewGroup);
ContentType.WEB -> createPostViewHolder(viewGroup);
ContentType.POST -> createPostViewHolder(viewGroup);
ContentType.PLAYLIST -> createPlaylistViewHolder(viewGroup);
ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup);
@@ -76,8 +76,8 @@ class PreviewLockedView : LinearLayout {
_textLockedUrl.text = content.unlockUrl ?: "";
}
else {
_imageChannelThumbnail.setImageResource(0);
_imageVideoThumbnail.setImageResource(0);
_imageChannelThumbnail.setImageDrawable(null);
_imageVideoThumbnail.setImageDrawable(null);
_textLockedDescription.text = "";
_textLockedUrl.text = "";
}
@@ -21,9 +21,11 @@ import com.futo.platformplayer.R
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.article.IPlatformArticle
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
@@ -141,6 +143,16 @@ class PreviewPostView : LinearLayout {
content.content
else
""
} else if(content is IPlatformArticle) {
if(!content.summary.isNullOrEmpty())
content.summary ?: ""
else
""
} else if(content is JSWeb) {
if(!content.url.isNullOrEmpty())
"WEB:" + content.url
else
""
} else "";
if (content.name.isNullOrEmpty()) {
@@ -154,7 +166,14 @@ class PreviewPostView : LinearLayout {
if (content is IPlatformPost) {
setImages(content.thumbnails.filterNotNull());
} else {
}
else if(content is IPlatformArticle) {
if(content.thumbnails != null)
setImages(listOf(content.thumbnails!!));
else
setImages(null);
}
else {
setImages(null);
}
@@ -233,7 +233,7 @@ open class PreviewVideoView : LinearLayout {
}
else {
currentVideo = null;
_imageVideo.setImageResource(0);
_imageVideo.setImageDrawable(null);
_containerDuration.visibility = GONE;
_containerLive.visibility = GONE;
_timeBar?.visibility = GONE;
@@ -47,7 +47,7 @@ class SubscriptionGroupBarViewHolder(private val _viewGroup: ViewGroup) : AnyAda
if(img != null) {
img.setImageView(_image)
} else {
_image.setImageResource(0);
_image.setImageDrawable(null);
if(value is SubscriptionGroup.Add)
_image.setBackgroundColor(Color.DKGRAY);
@@ -88,7 +88,7 @@ class SubscriptionGroupListViewHolder(private val _viewGroup: ViewGroup) : AnyAd
if(img != null)
img.setImageView(_image)
else {
_image.setImageResource(0);
_image.setImageDrawable(null);
if(value is SubscriptionGroup.Add)
_image.setBackgroundColor(Color.DKGRAY);
@@ -628,12 +628,12 @@ class GestureControlView : LinearLayout {
private fun fastForwardTick() {
_fastForwardCounter++;
val seekOffset: Long = 10000;
val seekOffset: Long = Settings.instance.playback.getSeekOffset();
if (_rewinding) {
_textRewind.text = "${_fastForwardCounter * 10} " + context.getString(R.string.seconds);
_textRewind.text = "${_fastForwardCounter * seekOffset / 1_000} " + context.getString(R.string.seconds);
onSeek.emit(-seekOffset);
} else {
_textFastForward.text = "${_fastForwardCounter * 10} " + context.getString(R.string.seconds);
_textFastForward.text = "${_fastForwardCounter * seekOffset / 1_000} " + context.getString(R.string.seconds);
onSeek.emit(seekOffset);
}
}
@@ -735,24 +735,43 @@ class GestureControlView : LinearLayout {
_animatorBrightness?.start();
}
fun saveBrightness() {
try {
_originalBrightnessMode = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE)
val brightness = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS)
_brightnessFactor = brightness / 255.0f;
Log.i(TAG, "Starting brightness brightness: $brightness, _brightnessFactor: $_brightnessFactor, _originalBrightnessMode: $_originalBrightnessMode")
_originalBrightnessFactor = _brightnessFactor
} catch (e: Throwable) {
Settings.instance.gestureControls.useSystemBrightness = false
Settings.instance.save()
UIDialogs.toast(context, "useSystemBrightness disabled due to an error")
}
}
fun restoreBrightness() {
if (Settings.instance.gestureControls.restoreSystemBrightness) {
onBrightnessAdjusted.emit(_originalBrightnessFactor)
if (android.provider.Settings.System.canWrite(context)) {
Log.i(TAG, "Restoring system brightness mode _originalBrightnessMode: $_originalBrightnessMode")
android.provider.Settings.System.putInt(
context.contentResolver,
android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE,
_originalBrightnessMode
)
}
}
}
fun setFullscreen(isFullScreen: Boolean) {
resetZoomPan()
if (isFullScreen) {
if (Settings.instance.gestureControls.useSystemBrightness) {
try {
_originalBrightnessMode = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE)
val brightness = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS)
_brightnessFactor = brightness / 255.0f;
Log.i(TAG, "Starting brightness brightness: $brightness, _brightnessFactor: $_brightnessFactor, _originalBrightnessMode: $_originalBrightnessMode")
_originalBrightnessFactor = _brightnessFactor
} catch (e: Throwable) {
Settings.instance.gestureControls.useSystemBrightness = false
Settings.instance.save()
UIDialogs.toast(context, "useSystemBrightness disabled due to an error")
}
saveBrightness()
}
if (Settings.instance.gestureControls.useSystemVolume) {
@@ -766,19 +785,7 @@ class GestureControlView : LinearLayout {
onSoundAdjusted.emit(_soundFactor);
} else {
if (Settings.instance.gestureControls.useSystemBrightness) {
if (Settings.instance.gestureControls.restoreSystemBrightness) {
onBrightnessAdjusted.emit(_originalBrightnessFactor)
if (android.provider.Settings.System.canWrite(context)) {
Log.i(TAG, "Restoring system brightness mode _originalBrightnessMode: $_originalBrightnessMode")
android.provider.Settings.System.putInt(
context.contentResolver,
android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE,
_originalBrightnessMode
)
}
}
restoreBrightness()
} else {
onBrightnessAdjusted.emit(1.0f);
}
@@ -803,4 +810,4 @@ class GestureControlView : LinearLayout {
const val EXIT_DURATION_FAST_FORWARD: Long = 600;
const val TAG = "GestureControlView";
}
}
}
@@ -21,10 +21,13 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.casting.AirPlayCastingDevice
import com.futo.platformplayer.casting.ChromecastCastingDevice
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.formatDuration
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.behavior.GestureControlView
import kotlinx.coroutines.CoroutineScope
@@ -61,6 +64,7 @@ class CastView : ConstraintLayout {
val onSettingsClick = Event0();
val onPrevious = Event0();
val onNext = Event0();
val onTimeJobTimeChanged_s = Event1<Long>()
@OptIn(UnstableApi::class)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
@@ -185,11 +189,11 @@ class CastView : ConstraintLayout {
}
fun setIsPlaying(isPlaying: Boolean) {
_updateTimeJob?.cancel();
stopTimeJob()
if(isPlaying) {
val d = StateCasting.instance.activeDevice;
if (d is AirPlayCastingDevice) {
if (d is AirPlayCastingDevice || d is ChromecastCastingDevice) {
_updateTimeJob = _scope.launch {
while (true) {
val device = StateCasting.instance.activeDevice;
@@ -198,7 +202,9 @@ class CastView : ConstraintLayout {
}
delay(1000);
setTime((device.expectedCurrentTime * 1000.0).toLong());
val time_ms = (device.expectedCurrentTime * 1000.0).toLong()
setTime(time_ms);
onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong())
}
}
}
@@ -28,17 +28,14 @@ class SlideUpMenuFilters {
private var _changed: Boolean = false;
private val _lifecycleScope: CoroutineScope;
private var _isChannelSearch = false;
var commonCapabilities: ResultCapabilities? = null;
constructor(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>, isChannelSearch: Boolean = false) {
constructor(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>) {
_lifecycleScope = lifecycleScope;
_container = container;
_enabledClientsIds = enabledClientsIds;
_filterValues = filterValues;
_isChannelSearch = isChannelSearch;
_slideUpMenuOverlay = SlideUpMenuOverlay(_container.context, _container, container.context.getString(R.string.filters), container.context.getString(R.string.done), true, listOf());
_slideUpMenuOverlay.onOK.subscribe {
onOK.emit(_enabledClientsIds, _changed);
@@ -51,10 +48,7 @@ class SlideUpMenuFilters {
private fun updateCommonCapabilities() {
_lifecycleScope.launch(Dispatchers.IO) {
try {
val caps = if(!_isChannelSearch)
StatePlatform.instance.getCommonSearchCapabilities(_enabledClientsIds);
else
StatePlatform.instance.getCommonSearchChannelContentsCapabilities(_enabledClientsIds);
val caps = StatePlatform.instance.getCommonSearchCapabilities(_enabledClientsIds);
synchronized(_filterValues) {
if (caps != null) {
val keysToRemove = arrayListOf<String>();
@@ -9,28 +9,28 @@ class PlatformIndicator : androidx.appcompat.widget.AppCompatImageView {
}
fun clearPlatform() {
setImageResource(0);
setImageDrawable(null);
}
fun setPlatformFromClientID(platformType : String?) {
if(platformType == null)
setImageResource(0);
setImageDrawable(null);
else {
val result = StatePlatform.instance.getPlatformIcon(platformType);
if (result != null)
result.setImageView(this);
else
setImageResource(0);
setImageDrawable(null);
}
}
fun setPlatformFromClientName(name: String?) {
if(name == null)
setImageResource(0);
setImageDrawable(null);
else {
val result = StatePlatform.instance.getPlatformIconByName(name);
if (result != null)
result.setImageView(this);
else
setImageResource(0);
setImageDrawable(null);
}
}
}
@@ -531,6 +531,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
fun setLoopVisible(visible: Boolean) {
_control_loop.visibility = if (visible) View.VISIBLE else View.GONE;
_control_loop_fullscreen.visibility = if (visible) View.VISIBLE else View.GONE;
if (StatePlayer.instance.loopVideo && !visible)
StatePlayer.instance.loopVideo = false
}
fun stopAllGestures() {
@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="m54,75 l25,-38h-50z"
android:strokeWidth=".83"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:fillColor="#FF000000"
android:pathData="m34,64c1,-1.1 3.6,-3.7 5.7,-5 2,-1.3 4.4,-5 5.4,-6.6 2.9,-4.3 9.4,-13 12,-15 0.5,-0.39 1.2,-1 1.5,-1.3 1,-2.3 4.6,-6.3 10,-3.5 0.5,-0.17 1.7,-0.21 2.3,-0.21 -0.44,0.3 -1.4,1.2 -1.5,2.3 -0.45,3.1 -2.1,4.9 -2.9,5.4 -0.56,3.6 -1.3,6.7 -3,7.8l1.5,2.7c2.3,2.4 7.1,7.7 7.8,9.3 -2.2,-0.73 -3.7,-1.4 -4.1,-1.7l4.1,5.7c-2.4,-0.19 -7.9,-1.8 -10,-6.6 0.95,2.6 1.9,5.8 2.2,7.1 -1.3,-1.1 -4.1,-4.2 -4.9,-8 0.22,3.7 0.19,6.4 0.14,7.3 -0.63,-0.58 -2.1,-2.4 -2.9,-5v4.3c-0.94,-1.3 -2.8,-4.5 -3,-6.9 0.16,2.7 0.06,4 -0.01,4.3l-3.6,-3.4c-0.95,0.51 -3.5,1.7 -6.1,2.2 -1.8,1.5 -4,5.3 -4.8,7v-2.2l-2.4,2.4 0.84,-2.5 -1.5,1.3c-0.35,0.21 -1.2,0.63 -1.6,0.63 0.17,-0.39 0.49,-0.82 0.63,-0.98l-2,0.77c0.23,-0.68 0.96,-2.2 2,-2.7 -1.5,0.56 -2,0.7 -2.2,0.7z"
android:strokeWidth=".2"/>
</vector>
@@ -233,7 +233,7 @@
android:isScrollContainer="true"
android:scrollbars="vertical"
android:maxHeight="200dp"
android:text="An error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurred" />
android:text="An error has occurred" />
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
+1 -1
View File
@@ -76,7 +76,7 @@
android:id="@+id/button_buy_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="$9.99 + Tax"
android:text="$19 + Tax"
android:textSize="14dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
@@ -0,0 +1,317 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:fitsSystemWindows="false"
android:background="@drawable/bottom_menu_border"
android:id="@+id/root"
android:clickable="true">
<LinearLayout
android:id="@+id/layout_top"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:layout_marginLeft="14dp"
android:layout_marginRight="14dp"
android:layout_marginTop="16dp">
<LinearLayout
android:id="@+id/layout_channel_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toLeftOf="@id/button_subscribe">
<com.futo.platformplayer.views.others.CreatorThumbnail
android:id="@+id/creator_thumbnail"
android:layout_width="27dp"
android:layout_height="27dp"
android:contentDescription="@string/cd_creator_thumbnail" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="6dp"
android:orientation="vertical">
<TextView
android:id="@+id/text_channel_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:layout_gravity="center"
android:layout_marginTop="-4dp"
android:ellipsize="end"
android:maxLines="1"
tools:text="Channel Name" />
<TextView
android:id="@+id/text_channel_meta"
android:layout_width="match_parent"
android:layout_weight="1"
android:layout_height="wrap_content"
android:textColor="#ACACAC"
android:textSize="9sp"
android:layout_gravity="center"
android:ellipsize="end"
android:maxLines="1"
tools:text="" />
</LinearLayout>
</LinearLayout>
<com.futo.platformplayer.views.subscriptions.SubscribeButton
android:id="@+id/button_subscribe"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/text_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="24 Things I Wish I Had Done Sooner (or my biggest regrets)"
android:fontFamily="@font/inter_medium"
android:textColor="@color/white"
android:textSize="17sp"
android:textIsSelectable="true"
android:layout_marginTop="6dp"
android:layout_marginLeft="14dp"
android:layout_marginRight="14dp" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="14dp"
android:layout_marginRight="14dp"
android:layout_marginTop="4dp">
<TextView
android:id="@+id/text_meta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="51K views • 3 years ago"
android:fontFamily="@font/inter_regular"
android:textColor="@color/gray_ac"
android:textSize="10dp"
android:layout_gravity="center_vertical"/>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_gravity="end|center_vertical"
android:gravity="center_vertical">
<LinearLayout
android:id="@+id/layout_rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:gravity="center_vertical">
<ImageView
android:id="@+id/image_like_icon"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@string/cd_image_like_icon"
app:srcCompat="@drawable/ic_thumb_up" />
<TextView
android:id="@+id/text_likes"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:layout_marginStart="8dp"
tools:text="500K"
android:textColor="@color/white"
android:textSize="10dp" />
<ImageView
android:id="@+id/image_dislike_icon"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@string/cd_image_dislike_icon"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
app:srcCompat="@drawable/ic_thumb_down" />
<TextView
android:id="@+id/text_dislikes"
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center_vertical"
android:layout_marginStart="8dp"
tools:text="500K"
android:textColor="@color/white"
android:textSize="10dp" />
</LinearLayout>
<com.futo.platformplayer.views.platform.PlatformIndicator
android:id="@+id/platform_indicator"
android:layout_width="25dp"
android:layout_height="25dp"
android:scaleType="centerInside"
tools:src="@drawable/ic_peertube"
android:layout_marginStart="8dp"
app:layout_constraintTop_toTopOf="@id/image_author_thumbnail"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="@id/image_author_thumbnail" />
</LinearLayout>
</FrameLayout>
<TextView
android:id="@+id/text_summary"
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center_vertical"
android:layout_marginStart="14dp"
android:layout_marginEnd="5dp"
android:textFontWeight="400"
tools:text="This is the summary of the article"
android:textColor="@color/white"
android:textSize="13sp" />
<LinearLayout
android:id="@+id/container_segments"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="10dp">
<com.futo.platformplayer.views.pills.PillRatingLikesDislikes
android:id="@+id/rating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="15dp" />
<Space android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<ImageButton
android:id="@+id/button_share"
android:layout_width="32dp"
android:layout_height="32dp"
android:contentDescription="@string/cd_button_share"
android:background="@drawable/background_button_round"
android:gravity="center"
android:layout_marginStart="5dp"
android:orientation="horizontal"
app:srcCompat="@drawable/ic_share"
app:tint="@color/white"
android:padding="8dp"
android:layout_marginEnd="15dp"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:scaleType="fitCenter" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_change_bottom_section"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@drawable/background_videodetail_description"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:layout_marginLeft="14dp"
android:layout_marginRight="14dp">
<Button
android:id="@+id/button_polycentric"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="?android:attr/selectableItemBackground"
android:text="Polycentric"
android:textColor="#fff"
android:textSize="10dp"
android:lines="1"
android:ellipsize="marquee"
android:padding="10dp" />
<Button
android:id="@+id/button_platform"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="?android:attr/selectableItemBackground"
android:text="Platform"
android:textColor="#fff"
android:textSize="10dp"
android:lines="1"
android:ellipsize="marquee"
android:padding="10dp" />
</LinearLayout>
<com.futo.platformplayer.views.comments.AddCommentView
android:id="@+id/add_comment_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginBottom="12dp"
android:layout_marginStart="28dp"
android:layout_marginEnd="28dp" />
</LinearLayout>
<com.futo.platformplayer.views.segments.CommentsList
android:id="@+id/comments_list"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/layout_loading_overlay"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#77000000"
android:elevation="4dp">
<ImageView
android:id="@+id/image_loader"
android:layout_width="80dp"
android:layout_height="80dp"
app:srcCompat="@drawable/ic_loader_animated"
android:layout_gravity="top|center_horizontal"
android:alpha="0.7"
android:layout_marginTop="80dp"
android:contentDescription="@string/loading" />
</FrameLayout>
<com.futo.platformplayer.views.overlays.RepliesOverlay
android:id="@+id/replies_overlay"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/overlay_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:elevation="15dp">
</FrameLayout>
</FrameLayout>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:fitsSystemWindows="false"
android:background="@drawable/bottom_menu_border"
android:id="@+id/root"
android:clickable="true">
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#111" />
<FrameLayout
android:id="@+id/layout_loading_overlay"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#77000000"
android:elevation="4dp">
<ImageView
android:id="@+id/image_loader"
android:layout_width="80dp"
android:layout_height="80dp"
app:srcCompat="@drawable/ic_loader_animated"
android:layout_gravity="top|center_horizontal"
android:alpha="0.7"
android:layout_marginTop="80dp"
android:contentDescription="@string/loading" />
</FrameLayout>
</FrameLayout>
+3 -1
View File
@@ -19,6 +19,7 @@
android:scaleType="fitCenter"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="12dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="@id/layout_video_thumbnail"
app:layout_constraintBottom_toBottomOf="@id/layout_video_thumbnail" />
@@ -30,7 +31,8 @@
app:layout_constraintLeft_toRightOf="@id/image_drag_drop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintDimensionRatio="W,1.7:1" >
app:layout_constraintDimensionRatio="W,1.7:1"
android:layout_marginStart="12dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/image_video_thumbnail"
@@ -1,6 +1,6 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="65dp"
android:layout_width="wrap_content"
android:layout_height="52dp"
android:orientation="vertical"
android:gravity="center"
@@ -9,14 +9,14 @@
<ImageView
android:id="@+id/image_button"
android:layout_width="25dp"
android:layout_width="wrap_content"
android:layout_height="25dp"
android:scaleType="fitCenter"
tools:srcCompat="@drawable/ic_sources"/>
<TextView
android:id="@+id/text_button"
android:layout_width="65dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="Home"
android:gravity="center"
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:paddingStart="7dp"
android:paddingEnd="7dp"
android:orientation="vertical"
android:id="@+id/root">
<ImageView
android:id="@+id/image_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp" />
<TextView
android:id="@+id/text_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="center"
android:textSize="10sp"
android:textColor="#999"
android:layout_marginBottom="5dp" />
</LinearLayout>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:paddingStart="10dp"
android:paddingEnd="10dp"
android:id="@+id/root">
<TextView
android:id="@+id/text_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:layout_weight="400"
android:textColor="#BBB"
android:layout_marginBottom="5dp"
android:layout_marginTop="5dp" />
</LinearLayout>
@@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
@@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
+8
View File
@@ -688,6 +688,14 @@
<item>Continuer la lecture</item>
<item>Superposition du lecteur</item>
</string-array>
<string-array name="seek_offset_duration">
<item>3 secondes</item>
<item>5 secondes</item>
<item>10 secondes</item>
<item>20 secondes</item>
<item>30 secondes</item>
<item>60 secondes</item>
</string-array>
<string-array name="resume_after_preview">
<item>Reprendre depuis le début</item>
<item>Reprendre après 10s</item>
+27 -17
View File
@@ -370,22 +370,22 @@
<string name="networking">Networking</string>
<string name="synchronization">Synchronization</string>
<string name="enabled_description">Enable feature</string>
<string name="broadcast">Broadcast</string>
<string name="broadcast_description">Allow device to broadcast presence</string>
<string name="connect_discovered">Connect discovered</string>
<string name="connect_discovered_description">Allow device to search for and initiate connection with known paired devices</string>
<string name="connect_last">Try connect last</string>
<string name="connect_last_description">Allow device to automatically connect to last known</string>
<string name="discover_through_relay">Discover through relay</string>
<string name="discover_through_relay_description">Allow paired devices to be discovered and connected to through the relay</string>
<string name="pair_through_relay">Pair through relay</string>
<string name="pair_through_relay_description">Allow devices to be paired through the relay</string>
<string name="connect_through_relay">Connection through relay</string>
<string name="connect_through_relay_description">Allow devices to be connected to through the relay</string>
<string name="connect_local_direct_through_relay">Connect direct through relay</string>
<string name="connect_local_direct_through_relay_description">Allow devices to be directly locally connected to through information discovered from the relay</string>
<string name="local_connections">Local connections</string>
<string name="local_connections_description">Allow device to be directly locally connected</string>
<string name="broadcast">mDNS Broadcast</string>
<string name="broadcast_description">Allow device to broadcast presence using mDNS</string>
<string name="connect_discovered">mDNS Connect</string>
<string name="connect_discovered_description">Allow device to search for and initiate connection with known paired devices using mDNS</string>
<string name="connect_last">Connect Last Known</string>
<string name="connect_last_description">Allow device to automatically connect to last known endpoints</string>
<string name="discover_through_relay">Relay Enable</string>
<string name="discover_through_relay_description">Allow device to use a relay for discovery/relaying connection</string>
<string name="pair_through_relay">Relay Pairing</string>
<string name="pair_through_relay_description">Allow device to be paired through the relay</string>
<string name="connect_through_relay">Relay Connect Relayed</string>
<string name="connect_through_relay_description">Allow device to be connected to using a relayed connection</string>
<string name="connect_local_direct_through_relay">Relay Connect Direct</string>
<string name="connect_local_direct_through_relay_description">Allow device to be directly connected to using relay published information</string>
<string name="local_connections">Bind Listener</string>
<string name="local_connections_description">Allow device to be directly connected to</string>
<string name="gesture_controls">Gesture controls</string>
<string name="volume_slider">Volume slider</string>
<string name="volume_slider_descr">Enable slide gesture to change volume</string>
@@ -424,6 +424,8 @@
<string name="allow_full_screen_portrait">Allow full-screen portrait when watching horizontal videos</string>
<string name="delete_watchlist_on_finish">Delete from WatchLater when watched</string>
<string name="delete_watchlist_on_finish_description">After you leave a video that you mostly watched, it will be removed from watch later.</string>
<string name="seek_offset">Seek duration</string>
<string name="seek_offset_description">Fast-Forward / Fast-Rewind duration</string>
<string name="background_switch_audio">Switch to Audio in Background</string>
<string name="background_switch_audio_description">Optimize bandwidth usage by switching to audio-only stream in background if available, may cause stutter</string>
<string name="subscription_group_menu">Groups</string>
@@ -1076,6 +1078,14 @@
<item>Within 30 seconds of loss</item>
<item>Always</item>
</string-array>
<string-array name="seek_offset_duration">
<item>3 seconds</item>
<item>5 seconds</item>
<item>10 seconds</item>
<item>20 seconds</item>
<item>30 seconds</item>
<item>60 seconds</item>
</string-array>
<string-array name="rotation_zone">
<item>15</item>
<item>30</item>
@@ -1089,4 +1099,4 @@
<item>1500</item>
<item>2000</item>
</string-array>
</resources>
</resources>
+2 -1
View File
@@ -15,7 +15,8 @@
"e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json",
"89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json",
"8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json",
"273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json"
"273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json",
"9bb33039-8580-48d4-9849-21319ae845a4": "sources/crunchyroll/CrunchyrollConfig.json"
},
"SOURCES_EMBEDDED_DEFAULT": [
"35ae969a-a7db-11ed-afa1-0242ac120002"
+2 -1
View File
@@ -15,7 +15,8 @@
"e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json",
"89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json",
"8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json",
"273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json"
"273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json",
"9bb33039-8580-48d4-9849-21319ae845a4": "sources/crunchyroll/CrunchyrollConfig.json"
},
"SOURCES_EMBEDDED_DEFAULT": [
"35ae969a-a7db-11ed-afa1-0242ac120002"
+2 -2
View File
@@ -138,7 +138,7 @@ See [Pagers.md](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/doc
When you deploy your plugin, you'll need to add code signing for security.
See [Script Signing.md](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/docs/Script%Signing.md)
See [Script Signing.md](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/docs/Script%20Signing.md)
## Plugin Deployment
@@ -186,4 +186,4 @@ Make sure the signature is correctly generated and added. Also, ensure the versi
If you have any issues or need further assistance, feel free to reach out to us at:
https://chat.futo.org/login/
https://chat.futo.org/login/