mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-29 19:13:01 +02:00
Compare commits
140 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 715c60dc6e | |||
| 916d052688 | |||
| 993b812c3b | |||
| 43887586b5 | |||
| 03d53f21a3 | |||
| 23d7e8e5b6 | |||
| cce117c585 | |||
| 303bd1b805 | |||
| c7f4a40342 | |||
| 208c6c0776 | |||
| 7d5c8347ce | |||
| bd70131252 | |||
| 43a373eceb | |||
| 5bb3466ffe | |||
| 75e97ed008 | |||
| ee28604c11 | |||
| a7d89e1bfb | |||
| cbfd9ea559 | |||
| dae50c3bc3 | |||
| e651e59dc4 | |||
| 80d78761bf | |||
| fb85aa4f32 | |||
| 9635c95efe | |||
| 033a237488 | |||
| ec22c58822 | |||
| 274942b5ba | |||
| 94ab3da0e4 | |||
| 5d44f0f2b6 | |||
| f051e6b452 | |||
| 46a4284253 | |||
| 0a708c6892 | |||
| 0f96164dc3 | |||
| 91c4917021 | |||
| c32ebe016b | |||
| ea26eefc2d | |||
| 418f4a6075 | |||
| 0ec921709a | |||
| e0811cfd93 | |||
| f6b0778eb6 | |||
| 18aec34c0e | |||
| bd185776e7 | |||
| fca5fe38bb | |||
| 1c2c7b376d | |||
| 670df86114 | |||
| 55fb4d4562 | |||
| c703d018bd | |||
| 425a27e130 | |||
| bd1b0e875b | |||
| 1509c11f64 | |||
| 57c1097fbc | |||
| 1d1728b92b | |||
| 5f9f6dbde8 | |||
| cc3639180b | |||
| 8aa4de7522 | |||
| ed1f7e7c72 | |||
| 1ecd1f5e04 | |||
| 1aa9adc899 | |||
| f8b2da93b9 | |||
| b794ff47ef | |||
| 6962a0547a | |||
| b906c1d36b | |||
| af337b1874 | |||
| 542235cca0 | |||
| f5673425b7 | |||
| 94965cf3ba | |||
| 120ded5274 | |||
| 705eb6a3fa | |||
| 1eb62b31d2 | |||
| b145187fa8 | |||
| 4da1e44fd1 | |||
| 4e70279982 | |||
| 233c8ee26e | |||
| 875adb4d79 | |||
| 456514c4d4 | |||
| dac1918b95 | |||
| 1d7429ad86 | |||
| 5d0e6615ab | |||
| dc415df8c0 | |||
| 45ce251c4c | |||
| 2bc702112f | |||
| abd73bf797 | |||
| e7e67b9572 | |||
| 1a58b693c1 | |||
| 50ecb909b4 | |||
| 5e480be8db | |||
| 48a67e51a6 | |||
| 5052bad824 | |||
| 5be92052bb | |||
| e20945692e | |||
| 191a6e2460 | |||
| c813fb4fad | |||
| bf7001b578 | |||
| 18102a2a73 | |||
| 780c1dbde1 | |||
| 879aab0d99 | |||
| 6f37bc2f5d | |||
| fc59b841d6 | |||
| c07fcdd489 | |||
| a49db10ade | |||
| 77bae98d77 | |||
| 254df7211c | |||
| f9caab48c4 | |||
| e0b5e7b808 | |||
| ac3a8da002 | |||
| 1aa45c2156 | |||
| 3cf8abd409 | |||
| db8426779c | |||
| b419e033f3 | |||
| d686fa327b | |||
| a1ce5eda43 | |||
| 1e790d1aa9 | |||
| d1d304b758 | |||
| e12b500144 | |||
| bd77651a1e | |||
| 35dc186395 | |||
| 07e78e0d12 | |||
| 5b8905c1d2 | |||
| 158a27cbae | |||
| e3800426c9 | |||
| 4acc867634 | |||
| 1a061268de | |||
| 5091a5485a | |||
| f8f1cababe | |||
| ad46841397 | |||
| 20fb1e0fd0 | |||
| 38b9fe3017 | |||
| bdae35b1a8 | |||
| 470b7bd2e5 | |||
| 9014fb581d | |||
| 7ffa6b1bb3 | |||
| 3cd4b4503f | |||
| d63fa521a1 | |||
| ca781dfe15 | |||
| 4bc561ceab | |||
| 3d258180bd | |||
| d5cab0910e | |||
| d4ccf232c1 | |||
| daf1d42a0f | |||
| a1d460385d | |||
| d2ed0c65ca |
@@ -0,0 +1,2 @@
|
|||||||
|
aar/* filter=lfs diff=lfs merge=lfs -text
|
||||||
|
app/aar/* filter=lfs diff=lfs merge=lfs -text
|
||||||
+12
@@ -94,3 +94,15 @@
|
|||||||
[submodule "app/src/unstable/assets/sources/tedtalks"]
|
[submodule "app/src/unstable/assets/sources/tedtalks"]
|
||||||
path = app/src/unstable/assets/sources/tedtalks
|
path = app/src/unstable/assets/sources/tedtalks
|
||||||
url = ../plugins/tedtalks.git
|
url = ../plugins/tedtalks.git
|
||||||
|
[submodule "app/src/stable/assets/sources/curiositystream"]
|
||||||
|
path = app/src/stable/assets/sources/curiositystream
|
||||||
|
url = ../plugins/curiositystream.git
|
||||||
|
[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
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ea10d3c5562c9f449a4e89e9c3dfcf881ed79a952f3409bc005bcc62c2cf4b81
|
||||||
|
size 65512557
|
||||||
+2
-1
@@ -197,7 +197,8 @@ dependencies {
|
|||||||
implementation 'org.jsoup:jsoup:1.15.3'
|
implementation 'org.jsoup:jsoup:1.15.3'
|
||||||
implementation 'com.google.android.flexbox:flexbox:3.0.0'
|
implementation 'com.google.android.flexbox:flexbox:3.0.0'
|
||||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||||
implementation 'com.arthenica:ffmpeg-kit-full: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 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
|
||||||
implementation 'com.github.dhaval2404:imagepicker:2.1'
|
implementation 'com.github.dhaval2404:imagepicker:2.1'
|
||||||
implementation 'com.google.zxing:core:3.4.1'
|
implementation 'com.google.zxing:core:3.4.1'
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
<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.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||||
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
|
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
|
||||||
|
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -55,7 +56,7 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.MainActivity"
|
android:name=".activities.MainActivity"
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout"
|
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||||
android:launchMode="singleInstance"
|
android:launchMode="singleInstance"
|
||||||
@@ -239,4 +240,4 @@
|
|||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ let Type = {
|
|||||||
Text: {
|
Text: {
|
||||||
RAW: 0,
|
RAW: 0,
|
||||||
HTML: 1,
|
HTML: 1,
|
||||||
MARKUP: 2
|
MARKUP: 2,
|
||||||
|
CODE: 3
|
||||||
},
|
},
|
||||||
Chapter: {
|
Chapter: {
|
||||||
NORMAL: 0,
|
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) {
|
constructor(obj) {
|
||||||
super(obj, 3);
|
super(obj, 3);
|
||||||
obj = obj ?? {};
|
obj = obj ?? {};
|
||||||
this.plugin_type = "PlatformArticleDetails";
|
this.plugin_type = "PlatformArticleDetails";
|
||||||
this.rating = obj.rating ?? new RatingLikes(-1);
|
this.rating = obj.rating ?? new RatingLikes(-1);
|
||||||
this.summary = obj.summary ?? "";
|
|
||||||
this.segments = obj.segments ?? [];
|
this.segments = obj.segments ?? [];
|
||||||
this.thumbnails = obj.thumbnails ?? new Thumbnails([]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class ArticleSegment {
|
class ArticleSegment {
|
||||||
@@ -315,9 +340,17 @@ class ArticleTextSegment extends ArticleSegment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
class ArticleImagesSegment extends ArticleSegment {
|
class ArticleImagesSegment extends ArticleSegment {
|
||||||
constructor(images) {
|
constructor(images, caption) {
|
||||||
super(2);
|
super(2);
|
||||||
this.images = images;
|
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 {
|
class ArticleNestedSegment extends ArticleSegment {
|
||||||
@@ -595,6 +628,8 @@ class PlatformComment {
|
|||||||
this.date = obj.date ?? 0;
|
this.date = obj.date ?? 0;
|
||||||
this.replyCount = obj.replyCount ?? 0;
|
this.replyCount = obj.replyCount ?? 0;
|
||||||
this.context = obj.context ?? {};
|
this.context = obj.context ?? {};
|
||||||
|
if(obj.getReplies)
|
||||||
|
this.getReplies = obj.getReplies;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -399,9 +399,11 @@ fun String.matchesDomain(queryDomain: String): Boolean {
|
|||||||
|
|
||||||
fun String.getSubdomainWildcardQuery(): String {
|
fun String.getSubdomainWildcardQuery(): String {
|
||||||
val domainParts = this.split(".");
|
val domainParts = this.split(".");
|
||||||
val sldParts = "." + domainParts[domainParts.size - 2].lowercase() + "." + domainParts[domainParts.size - 1].lowercase();
|
var wildcardDomain = if(domainParts.size > 2)
|
||||||
if(slds.contains(sldParts))
|
"." + domainParts.drop(1).joinToString(".")
|
||||||
return "." + domainParts.drop(domainParts.size - 3).joinToString(".");
|
|
||||||
else
|
else
|
||||||
return "." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
"." + domainParts.joinToString(".");
|
||||||
|
if(slds.contains(wildcardDomain.lowercase()))
|
||||||
|
"." + domainParts.joinToString(".");
|
||||||
|
return wildcardDomain;
|
||||||
}
|
}
|
||||||
@@ -217,6 +217,8 @@ private fun ByteArray.toInetAddress(): InetAddress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? {
|
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? {
|
||||||
|
ensureNotMainThread()
|
||||||
|
|
||||||
val timeout = 2000
|
val timeout = 2000
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import java.net.InetAddress
|
|||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.net.URISyntaxException
|
import java.net.URISyntaxException
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
//Syntax sugaring
|
//Syntax sugaring
|
||||||
inline fun <reified T> Any.assume(): T?{
|
inline fun <reified T> Any.assume(): T?{
|
||||||
@@ -33,13 +36,37 @@ fun Boolean?.toYesNo(): String {
|
|||||||
fun InetAddress?.toUrlAddress(): String {
|
fun InetAddress?.toUrlAddress(): String {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is Inet6Address -> {
|
is Inet6Address -> {
|
||||||
"[${hostAddress}]"
|
val hostAddr = this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
|
||||||
|
val index = hostAddr.indexOf('%')
|
||||||
|
if (index != -1) {
|
||||||
|
val addrPart = hostAddr.substring(0, index)
|
||||||
|
val scopeId = hostAddr.substring(index + 1)
|
||||||
|
"[${addrPart}%25${scopeId}]" // %25 is URL-encoded '%'
|
||||||
|
} else {
|
||||||
|
"[$hostAddr]"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is Inet4Address -> {
|
is Inet4Address -> {
|
||||||
hostAddress
|
this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
throw Exception("Invalid address type")
|
throw Exception("Invalid address type")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Long?.sToOffsetDateTimeUTC(): OffsetDateTime {
|
||||||
|
if (this == null || this < 0)
|
||||||
|
return OffsetDateTime.MIN
|
||||||
|
if(this > 4070912400)
|
||||||
|
return OffsetDateTime.MAX;
|
||||||
|
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(this), ZoneOffset.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Long?.msToOffsetDateTimeUTC(): OffsetDateTime {
|
||||||
|
if (this == null || this < 0)
|
||||||
|
return OffsetDateTime.MIN
|
||||||
|
if(this > 4070912400)
|
||||||
|
return OffsetDateTime.MAX;
|
||||||
|
return OffsetDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneOffset.UTC)
|
||||||
}
|
}
|
||||||
@@ -499,6 +499,22 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22)
|
@FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22)
|
||||||
var deleteFromWatchLaterAuto: Boolean = true;
|
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)
|
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
||||||
@@ -590,7 +606,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4)
|
@FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4)
|
||||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
var allowIpv6: Boolean = false;
|
var allowIpv6: Boolean = true;
|
||||||
|
|
||||||
/*TODO: Should we have a different casting quality?
|
/*TODO: Should we have a different casting quality?
|
||||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||||
@@ -926,7 +942,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@Serializable
|
@Serializable
|
||||||
class Synchronization {
|
class Synchronization {
|
||||||
@FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enabled_description, 1)
|
@FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enabled_description, 1)
|
||||||
var enabled: Boolean = true;
|
var enabled: Boolean = false;
|
||||||
|
|
||||||
@FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1)
|
@FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1)
|
||||||
var broadcast: Boolean = false;
|
var broadcast: Boolean = false;
|
||||||
@@ -948,6 +964,9 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.connect_local_direct_through_relay, FieldForm.TOGGLE, R.string.connect_local_direct_through_relay_description, 3)
|
@FormField(R.string.connect_local_direct_through_relay, FieldForm.TOGGLE, R.string.connect_local_direct_through_relay_description, 3)
|
||||||
var connectLocalDirectThroughRelay: Boolean = true;
|
var connectLocalDirectThroughRelay: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3)
|
||||||
|
var localConnections: Boolean = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
|
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
|
||||||
@@ -1016,4 +1035,4 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,14 @@ import android.app.NotificationManager
|
|||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
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 androidx.recyclerview.widget.RecyclerView
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.activities.SettingsActivity
|
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.Subscription
|
||||||
import com.futo.platformplayer.models.SubscriptionGroup
|
import com.futo.platformplayer.models.SubscriptionGroup
|
||||||
import com.futo.platformplayer.parsers.HLS
|
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.StateApp
|
||||||
import com.futo.platformplayer.states.StateDownloads
|
import com.futo.platformplayer.states.StateDownloads
|
||||||
import com.futo.platformplayer.states.StateHistory
|
import com.futo.platformplayer.states.StateHistory
|
||||||
@@ -63,6 +72,8 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
class UISlideOverlays {
|
class UISlideOverlays {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -299,6 +310,7 @@ class UISlideOverlays {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
|
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
|
||||||
val items = arrayListOf<View>(LoaderView(container.context))
|
val items = arrayListOf<View>(LoaderView(container.context))
|
||||||
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
|
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()
|
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||||
?: throw Exception("Master playlist content is empty")
|
?: throw Exception("Master playlist content is empty")
|
||||||
|
|
||||||
|
val resolvedPlaylistUrl = masterPlaylistResponse.url
|
||||||
|
|
||||||
val videoButtons = arrayListOf<SlideUpMenuItem>()
|
val videoButtons = arrayListOf<SlideUpMenuItem>()
|
||||||
val audioButtons = arrayListOf<SlideUpMenuItem>()
|
val audioButtons = arrayListOf<SlideUpMenuItem>()
|
||||||
//TODO: Implement subtitles
|
//TODO: Implement subtitles
|
||||||
@@ -322,55 +336,103 @@ class UISlideOverlays {
|
|||||||
|
|
||||||
val masterPlaylist: HLS.MasterPlaylist
|
val masterPlaylist: HLS.MasterPlaylist
|
||||||
try {
|
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 estSize = VideoHelper.estimateSourceSize(variant);
|
||||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||||
audioButtons.add(SlideUpMenuItem(
|
audioButtons.add(SlideUpMenuItem(
|
||||||
container.context,
|
container.context,
|
||||||
R.drawable.ic_music,
|
R.drawable.ic_music,
|
||||||
it.name,
|
variant.name,
|
||||||
listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
|
listOf(variant.language, variant.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
|
||||||
(prefix + it.codec).trim(),
|
(prefix + variant.codec).trim(),
|
||||||
tag = it,
|
tag = variant,
|
||||||
call = {
|
call = {
|
||||||
selectedAudioVariant = it
|
selectedAudioVariant = variant
|
||||||
slideUpMenuOverlay.selectOption(audioButtons, it)
|
slideUpMenuOverlay.selectOption(audioButtons, variant)
|
||||||
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))
|
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>()
|
val newItems = arrayListOf<View>()
|
||||||
@@ -398,11 +460,11 @@ class UISlideOverlays {
|
|||||||
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
|
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (source is IHLSManifestSource) {
|
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")
|
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
|
||||||
slideUpMenuOverlay.hide()
|
slideUpMenuOverlay.hide()
|
||||||
} else if (source is IHLSManifestAudioSource) {
|
} 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")
|
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
|
||||||
slideUpMenuOverlay.hide()
|
slideUpMenuOverlay.hide()
|
||||||
} else {
|
} else {
|
||||||
@@ -984,26 +1046,30 @@ class UISlideOverlays {
|
|||||||
+ actions).filterNotNull()
|
+ actions).filterNotNull()
|
||||||
));
|
));
|
||||||
items.add(
|
items.add(
|
||||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto",
|
SlideUpMenuGroup(
|
||||||
SlideUpMenuItem(container.context,
|
container.context, container.context.getString(R.string.add_to), "addto",
|
||||||
|
SlideUpMenuItem(
|
||||||
|
container.context,
|
||||||
R.drawable.ic_queue_add,
|
R.drawable.ic_queue_add,
|
||||||
container.context.getString(R.string.add_to_queue),
|
container.context.getString(R.string.add_to_queue),
|
||||||
"${queue.size} " + container.context.getString(R.string.videos),
|
"${queue.size} " + container.context.getString(R.string.videos),
|
||||||
tag = "queue",
|
tag = "queue",
|
||||||
call = { StatePlayer.instance.addToQueue(video); }),
|
call = { StatePlayer.instance.addToQueue(video); }),
|
||||||
SlideUpMenuItem(container.context,
|
SlideUpMenuItem(
|
||||||
|
container.context,
|
||||||
R.drawable.ic_watchlist_add,
|
R.drawable.ic_watchlist_add,
|
||||||
"${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "",
|
"${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "",
|
||||||
"${watchLater.size} " + container.context.getString(R.string.videos),
|
"${watchLater.size} " + container.context.getString(R.string.videos),
|
||||||
tag = "watch later",
|
tag = "watch later",
|
||||||
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }),
|
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }),
|
||||||
SlideUpMenuItem(container.context,
|
SlideUpMenuItem(
|
||||||
|
container.context,
|
||||||
R.drawable.ic_history,
|
R.drawable.ic_history,
|
||||||
container.context.getString(R.string.add_to_history),
|
container.context.getString(R.string.add_to_history),
|
||||||
"Mark as watched",
|
"Mark as watched",
|
||||||
tag = "history",
|
tag = "history",
|
||||||
call = { StateHistory.instance.markAsWatched(video); }),
|
call = { StateHistory.instance.markAsWatched(video); }),
|
||||||
));
|
));
|
||||||
|
|
||||||
val playlistItems = arrayListOf<SlideUpMenuItem>();
|
val playlistItems = arrayListOf<SlideUpMenuItem>();
|
||||||
playlistItems.add(SlideUpMenuItem(
|
playlistItems.add(SlideUpMenuItem(
|
||||||
@@ -1067,14 +1133,17 @@ class UISlideOverlays {
|
|||||||
val queue = StatePlayer.instance.getQueue();
|
val queue = StatePlayer.instance.getQueue();
|
||||||
val watchLater = StatePlaylists.instance.getWatchLater();
|
val watchLater = StatePlaylists.instance.getWatchLater();
|
||||||
items.add(
|
items.add(
|
||||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other",
|
SlideUpMenuGroup(
|
||||||
SlideUpMenuItem(container.context,
|
container.context, container.context.getString(R.string.other), "other",
|
||||||
|
SlideUpMenuItem(
|
||||||
|
container.context,
|
||||||
R.drawable.ic_queue_add,
|
R.drawable.ic_queue_add,
|
||||||
container.context.getString(R.string.queue),
|
container.context.getString(R.string.queue),
|
||||||
"${queue.size} " + container.context.getString(R.string.videos),
|
"${queue.size} " + container.context.getString(R.string.videos),
|
||||||
tag = "queue",
|
tag = "queue",
|
||||||
call = { StatePlayer.instance.addToQueue(video); }),
|
call = { StatePlayer.instance.addToQueue(video); }),
|
||||||
SlideUpMenuItem(container.context,
|
SlideUpMenuItem(
|
||||||
|
container.context,
|
||||||
R.drawable.ic_watchlist_add,
|
R.drawable.ic_watchlist_add,
|
||||||
StatePlayer.TYPE_WATCHLATER,
|
StatePlayer.TYPE_WATCHLATER,
|
||||||
"${watchLater.size} " + container.context.getString(R.string.videos),
|
"${watchLater.size} " + container.context.getString(R.string.videos),
|
||||||
@@ -1083,7 +1152,7 @@ class UISlideOverlays {
|
|||||||
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true))
|
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true))
|
||||||
UIDialogs.appToast("Added to watch later", false);
|
UIDialogs.appToast("Added to watch later", false);
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
val playlistItems = arrayListOf<SlideUpMenuItem>();
|
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() };
|
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 {
|
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>): SlideUpMenuFilters {
|
||||||
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues, isChannelSearch);
|
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues);
|
||||||
overlay.show();
|
overlay.show();
|
||||||
return overlay;
|
return overlay;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ import java.io.ByteArrayOutputStream
|
|||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
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.nio.ByteBuffer
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
@@ -331,4 +337,98 @@ fun ByteArray.fromGzip(): ByteArray {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return outputStream.toByteArray()
|
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.16–31/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.annotation.SuppressLint
|
||||||
import android.app.AlertDialog
|
import android.app.AlertDialog
|
||||||
import android.content.ComponentName
|
import android.app.UiModeManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.media.AudioManager
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.StrictMode
|
import android.os.StrictMode
|
||||||
import android.os.StrictMode.VmPolicy
|
import android.os.StrictMode.VmPolicy
|
||||||
@@ -22,6 +22,7 @@ import android.widget.ImageView
|
|||||||
import androidx.activity.result.ActivityResult
|
import androidx.activity.result.ActivityResult
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.annotation.OptIn
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.constraintlayout.motion.widget.MotionLayout
|
import androidx.constraintlayout.motion.widget.MotionLayout
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
@@ -31,6 +32,8 @@ import androidx.fragment.app.Fragment
|
|||||||
import androidx.fragment.app.FragmentContainerView
|
import androidx.fragment.app.FragmentContainerView
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.whenStateAtLeast
|
||||||
|
import androidx.lifecycle.withStateAtLeast
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.futo.platformplayer.BuildConfig
|
import com.futo.platformplayer.BuildConfig
|
||||||
import com.futo.platformplayer.R
|
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.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.casting.StateCasting
|
import com.futo.platformplayer.casting.StateCasting
|
||||||
import com.futo.platformplayer.constructs.Event1
|
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.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.BrowserFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.BuyFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.BuyFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
|
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.SuggestionsFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.TutorialFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.TutorialFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
|
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.WatchLaterFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.WebDetailFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
|
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.logging.Logger
|
||||||
import com.futo.platformplayer.models.ImportCache
|
import com.futo.platformplayer.models.ImportCache
|
||||||
import com.futo.platformplayer.models.UrlVideoWithTime
|
import com.futo.platformplayer.models.UrlVideoWithTime
|
||||||
import com.futo.platformplayer.receivers.MediaButtonReceiver
|
|
||||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StateBackup
|
import com.futo.platformplayer.states.StateBackup
|
||||||
@@ -147,6 +153,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
//Frags Main
|
//Frags Main
|
||||||
lateinit var _fragMainHome: HomeFragment;
|
lateinit var _fragMainHome: HomeFragment;
|
||||||
lateinit var _fragPostDetail: PostDetailFragment;
|
lateinit var _fragPostDetail: PostDetailFragment;
|
||||||
|
lateinit var _fragArticleDetail: ArticleDetailFragment;
|
||||||
|
lateinit var _fragWebDetail: WebDetailFragment;
|
||||||
lateinit var _fragMainVideoSearchResults: ContentSearchResultsFragment;
|
lateinit var _fragMainVideoSearchResults: ContentSearchResultsFragment;
|
||||||
lateinit var _fragMainCreatorSearchResults: CreatorSearchResultsFragment;
|
lateinit var _fragMainCreatorSearchResults: CreatorSearchResultsFragment;
|
||||||
lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment;
|
lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment;
|
||||||
@@ -186,6 +194,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
|
|
||||||
private var _isVisible = true;
|
private var _isVisible = true;
|
||||||
private var _wasStopped = false;
|
private var _wasStopped = false;
|
||||||
|
private var _privateModeEnabled = false
|
||||||
|
private var _pictureInPictureEnabled = false
|
||||||
|
private var _isFullscreen = false
|
||||||
|
|
||||||
private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||||
@@ -197,7 +208,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
runBlocking {
|
lifecycleScope.launch {
|
||||||
handleUrlAll(content)
|
handleUrlAll(content)
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -263,6 +274,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
StateApp.instance.mainAppStarting(this);
|
StateApp.instance.mainAppStarting(this);
|
||||||
|
|
||||||
super.onCreate(savedInstanceState);
|
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);
|
setContentView(R.layout.activity_main);
|
||||||
setNavigationBarColorAndIcons();
|
setNavigationBarColorAndIcons();
|
||||||
if (Settings.instance.playback.allowVideoToGoUnderCutout)
|
if (Settings.instance.playback.allowVideoToGoUnderCutout)
|
||||||
@@ -270,7 +285,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||||
|
|
||||||
runBlocking {
|
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
|
//Preload common files to memory
|
||||||
@@ -314,6 +333,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
_fragMainPlaylist = PlaylistFragment.newInstance();
|
_fragMainPlaylist = PlaylistFragment.newInstance();
|
||||||
_fragMainRemotePlaylist = RemotePlaylistFragment.newInstance();
|
_fragMainRemotePlaylist = RemotePlaylistFragment.newInstance();
|
||||||
_fragPostDetail = PostDetailFragment.newInstance();
|
_fragPostDetail = PostDetailFragment.newInstance();
|
||||||
|
_fragArticleDetail = ArticleDetailFragment.newInstance();
|
||||||
|
_fragWebDetail = WebDetailFragment.newInstance();
|
||||||
_fragWatchlist = WatchLaterFragment.newInstance();
|
_fragWatchlist = WatchLaterFragment.newInstance();
|
||||||
_fragHistory = HistoryFragment.newInstance();
|
_fragHistory = HistoryFragment.newInstance();
|
||||||
_fragSourceDetail = SourceDetailFragment.newInstance();
|
_fragSourceDetail = SourceDetailFragment.newInstance();
|
||||||
@@ -355,22 +376,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
_fragMainSubscriptionsFeed.setPreviewsEnabled(true);
|
_fragMainSubscriptionsFeed.setPreviewsEnabled(true);
|
||||||
_fragContainerVideoDetail.visibility = View.INVISIBLE;
|
_fragContainerVideoDetail.visibility = View.INVISIBLE;
|
||||||
updateSegmentPaddings();
|
updateSegmentPaddings();
|
||||||
|
updatePrivateModeVisibility()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
_buttonIncognito = findViewById(R.id.incognito_button);
|
_buttonIncognito = findViewById(R.id.incognito_button);
|
||||||
_buttonIncognito.elevation = -99f;
|
updatePrivateModeVisibility()
|
||||||
_buttonIncognito.alpha = 0f;
|
|
||||||
StateApp.instance.privateModeChanged.subscribe {
|
StateApp.instance.privateModeChanged.subscribe {
|
||||||
//Messing with visibility causes some issues with layout ordering?
|
//Messing with visibility causes some issues with layout ordering?
|
||||||
if (it) {
|
_privateModeEnabled = it
|
||||||
_buttonIncognito.elevation = 99f;
|
updatePrivateModeVisibility()
|
||||||
_buttonIncognito.alpha = 1f;
|
|
||||||
} else {
|
|
||||||
_buttonIncognito.elevation = -99f;
|
|
||||||
_buttonIncognito.alpha = 0f;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_buttonIncognito.setOnClickListener {
|
_buttonIncognito.setOnClickListener {
|
||||||
if (!StateApp.instance.privateMode)
|
if (!StateApp.instance.privateMode)
|
||||||
return@setOnClickListener;
|
return@setOnClickListener;
|
||||||
@@ -387,19 +404,16 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
};
|
};
|
||||||
_fragVideoDetail.onFullscreenChanged.subscribe {
|
_fragVideoDetail.onFullscreenChanged.subscribe {
|
||||||
Logger.i(TAG, "onFullscreenChanged ${it}");
|
Logger.i(TAG, "onFullscreenChanged ${it}");
|
||||||
|
_isFullscreen = it
|
||||||
|
updatePrivateModeVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
if (it) {
|
_fragVideoDetail.onMinimize.subscribe {
|
||||||
_buttonIncognito.elevation = -99f;
|
updatePrivateModeVisibility()
|
||||||
_buttonIncognito.alpha = 0f;
|
}
|
||||||
} else {
|
|
||||||
if (StateApp.instance.privateMode) {
|
_fragVideoDetail.onMaximized.subscribe {
|
||||||
_buttonIncognito.elevation = 99f;
|
updatePrivateModeVisibility()
|
||||||
_buttonIncognito.alpha = 1f;
|
|
||||||
} else {
|
|
||||||
_buttonIncognito.elevation = -99f;
|
|
||||||
_buttonIncognito.alpha = 0f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
StatePlayer.instance.also {
|
StatePlayer.instance.also {
|
||||||
@@ -447,6 +461,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
_fragMainPlaylist.topBar = _fragTopBarNavigation;
|
_fragMainPlaylist.topBar = _fragTopBarNavigation;
|
||||||
_fragMainRemotePlaylist.topBar = _fragTopBarNavigation;
|
_fragMainRemotePlaylist.topBar = _fragTopBarNavigation;
|
||||||
_fragPostDetail.topBar = _fragTopBarNavigation;
|
_fragPostDetail.topBar = _fragTopBarNavigation;
|
||||||
|
_fragArticleDetail.topBar = _fragTopBarNavigation;
|
||||||
|
_fragWebDetail.topBar = _fragTopBarNavigation;
|
||||||
_fragWatchlist.topBar = _fragTopBarNavigation;
|
_fragWatchlist.topBar = _fragTopBarNavigation;
|
||||||
_fragHistory.topBar = _fragTopBarNavigation;
|
_fragHistory.topBar = _fragTopBarNavigation;
|
||||||
_fragSourceDetail.topBar = _fragTopBarNavigation;
|
_fragSourceDetail.topBar = _fragTopBarNavigation;
|
||||||
@@ -641,6 +657,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
private fun updatePrivateModeVisibility() {
|
||||||
|
if (_privateModeEnabled && (_fragVideoDetail.state == State.CLOSED || !_pictureInPictureEnabled && !_isFullscreen)) {
|
||||||
|
_buttonIncognito.elevation = 99f;
|
||||||
|
_buttonIncognito.alpha = 1f;
|
||||||
|
_buttonIncognito.translationY = if (_fragVideoDetail.state == State.MINIMIZED) -60.dp(resources).toFloat() else 0f
|
||||||
|
} else {
|
||||||
|
_buttonIncognito.elevation = -99f;
|
||||||
|
_buttonIncognito.alpha = 0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
Logger.v(TAG, "onResume")
|
Logger.v(TAG, "onResume")
|
||||||
@@ -692,7 +720,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
|
|
||||||
"VIDEO" -> {
|
"VIDEO" -> {
|
||||||
val url = intent.getStringExtra("VIDEO");
|
val url = intent.getStringExtra("VIDEO");
|
||||||
navigate(_fragVideoDetail, url);
|
navigateWhenReady(_fragVideoDetail, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
"IMPORT_OPTIONS" -> {
|
"IMPORT_OPTIONS" -> {
|
||||||
@@ -710,11 +738,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
"Sources" -> {
|
"Sources" -> {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed..
|
StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed..
|
||||||
navigate(_fragMainSources);
|
navigateWhenReady(_fragMainSources);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
"BROWSE_PLUGINS" -> {
|
"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 ->
|
Pair("grayjay") { req ->
|
||||||
StateApp.instance.contextOrNull?.let {
|
StateApp.instance.contextOrNull?.let {
|
||||||
if (it is MainActivity) {
|
if (it is MainActivity) {
|
||||||
@@ -732,8 +760,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (targetData != null) {
|
if (targetData != null) {
|
||||||
runBlocking {
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
handleUrlAll(targetData)
|
try {
|
||||||
|
handleUrlAll(targetData)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Unhandled exception in handleUrlAll", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (ex: Throwable) {
|
} catch (ex: Throwable) {
|
||||||
@@ -761,10 +793,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
startActivity(intent);
|
startActivity(intent);
|
||||||
} else if (url.startsWith("grayjay://video/")) {
|
} else if (url.startsWith("grayjay://video/")) {
|
||||||
val videoUrl = url.substring("grayjay://video/".length);
|
val videoUrl = url.substring("grayjay://video/".length);
|
||||||
navigate(_fragVideoDetail, videoUrl);
|
navigateWhenReady(_fragVideoDetail, videoUrl);
|
||||||
} else if (url.startsWith("grayjay://channel/")) {
|
} else if (url.startsWith("grayjay://channel/")) {
|
||||||
val channelUrl = url.substring("grayjay://channel/".length);
|
val channelUrl = url.substring("grayjay://channel/".length);
|
||||||
navigate(_fragMainChannel, channelUrl);
|
navigateWhenReady(_fragMainChannel, channelUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -830,29 +862,29 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
|
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
Logger.i(TAG, "handleUrl(url=$url) on 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");
|
Logger.i(TAG, "handleUrl(url=$url) found video client");
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (position > 0)
|
if (position > 0)
|
||||||
navigate(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true));
|
navigateWhenReady(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true));
|
||||||
else
|
else
|
||||||
navigate(_fragVideoDetail, url);
|
navigateWhenReady(_fragVideoDetail, url);
|
||||||
|
|
||||||
_fragVideoDetail.maximizeVideoDetail(true);
|
_fragVideoDetail.maximizeVideoDetail(true);
|
||||||
}
|
}
|
||||||
return@withContext true;
|
return@withContext true;
|
||||||
} else if (StatePlatform.instance.hasEnabledChannelClient(url)) {
|
} else if (StatePlatform.instance.hasEnabledChannelClient(url)) {
|
||||||
Logger.i(TAG, "handleUrl(url=$url) found channel client");
|
Logger.i(TAG, "handleUrl(url=$url) found channel client");
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
navigate(_fragMainChannel, url);
|
navigateWhenReady(_fragMainChannel, url);
|
||||||
delay(100);
|
delay(100);
|
||||||
_fragVideoDetail.minimizeVideoDetail();
|
_fragVideoDetail.minimizeVideoDetail();
|
||||||
};
|
};
|
||||||
return@withContext true;
|
return@withContext true;
|
||||||
} else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) {
|
} else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) {
|
||||||
Logger.i(TAG, "handleUrl(url=$url) found playlist client");
|
Logger.i(TAG, "handleUrl(url=$url) found playlist client");
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
navigate(_fragMainRemotePlaylist, url);
|
navigateWhenReady(_fragMainRemotePlaylist, url);
|
||||||
delay(100);
|
delay(100);
|
||||||
_fragVideoDetail.minimizeVideoDetail();
|
_fragVideoDetail.minimizeVideoDetail();
|
||||||
};
|
};
|
||||||
@@ -1064,6 +1096,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
Logger.v(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop")
|
Logger.v(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop")
|
||||||
_fragVideoDetail.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig);
|
_fragVideoDetail.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig);
|
||||||
Logger.v(TAG, "onPictureInPictureModeChanged Ready");
|
Logger.v(TAG, "onPictureInPictureModeChanged Ready");
|
||||||
|
|
||||||
|
_pictureInPictureEnabled = isInPictureInPictureMode
|
||||||
|
updatePrivateModeVisibility()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
@@ -1076,6 +1111,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
return fragCurrent is T;
|
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
|
* 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
|
* A parameter can be provided which becomes available in the onShow of said fragment
|
||||||
@@ -1200,6 +1247,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
PlaylistFragment::class -> _fragMainPlaylist as T;
|
PlaylistFragment::class -> _fragMainPlaylist as T;
|
||||||
RemotePlaylistFragment::class -> _fragMainRemotePlaylist as T;
|
RemotePlaylistFragment::class -> _fragMainRemotePlaylist as T;
|
||||||
PostDetailFragment::class -> _fragPostDetail as T;
|
PostDetailFragment::class -> _fragPostDetail as T;
|
||||||
|
ArticleDetailFragment::class -> _fragArticleDetail as T;
|
||||||
|
WebDetailFragment::class -> _fragWebDetail as T;
|
||||||
WatchLaterFragment::class -> _fragWatchlist as T;
|
WatchLaterFragment::class -> _fragWatchlist as T;
|
||||||
HistoryFragment::class -> _fragHistory as T;
|
HistoryFragment::class -> _fragHistory as T;
|
||||||
SourceDetailFragment::class -> _fragSourceDetail as T;
|
SourceDetailFragment::class -> _fragSourceDetail as T;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import android.widget.LinearLayout
|
|||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StateSync
|
import com.futo.platformplayer.states.StateSync
|
||||||
@@ -29,6 +31,16 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
if (StateApp.instance.contextOrNull == null) {
|
||||||
|
Logger.w(TAG, "No main activity, restarting main.")
|
||||||
|
val intent = Intent(this, MainActivity::class.java)
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
startActivity(intent)
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setContentView(R.layout.activity_sync_home)
|
setContentView(R.layout.activity_sync_home)
|
||||||
setNavigationBarColorAndIcons()
|
setNavigationBarColorAndIcons()
|
||||||
|
|
||||||
@@ -54,7 +66,6 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||||||
val view = _viewMap[publicKey]
|
val view = _viewMap[publicKey]
|
||||||
if (!session.isAuthorized) {
|
if (!session.isAuthorized) {
|
||||||
if (view != null) {
|
if (view != null) {
|
||||||
_layoutDevices.removeView(view)
|
|
||||||
_viewMap.remove(publicKey)
|
_viewMap.remove(publicKey)
|
||||||
}
|
}
|
||||||
return@launch
|
return@launch
|
||||||
@@ -89,6 +100,20 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||||||
updateEmptyVisibility()
|
updateEmptyVisibility()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
@@ -100,11 +125,12 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView {
|
private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView {
|
||||||
val connected = session?.connected ?: false
|
val connected = session?.connected ?: false
|
||||||
|
val authorized = session?.isAuthorized ?: false
|
||||||
|
|
||||||
syncDeviceView.setLinkType(session?.linkType ?: LinkType.None)
|
syncDeviceView.setLinkType(session?.linkType ?: LinkType.None)
|
||||||
.setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey)
|
.setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey)
|
||||||
//TODO: also display public key?
|
//TODO: also display public key?
|
||||||
.setStatus(if (connected) "Connected" else "Disconnected")
|
.setStatus(if (connected && authorized) "Connected" else "Disconnected or unauthorized")
|
||||||
return syncDeviceView
|
return syncDeviceView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class SyncPairActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
_layoutPairingSuccess.setOnClickListener {
|
_layoutPairingSuccess.setOnClickListener {
|
||||||
_layoutPairingSuccess.visibility = View.GONE
|
_layoutPairingSuccess.visibility = View.GONE
|
||||||
|
finish()
|
||||||
}
|
}
|
||||||
_layoutPairingError.setOnClickListener {
|
_layoutPairingError.setOnClickListener {
|
||||||
_layoutPairingError.visibility = View.GONE
|
_layoutPairingError.visibility = View.GONE
|
||||||
@@ -109,11 +110,17 @@ class SyncPairActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
StateSync.instance.connect(deviceInfo) { complete, message ->
|
StateSync.instance.syncService?.connect(deviceInfo) { complete, message ->
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
if (complete != null && complete) {
|
if (complete != null) {
|
||||||
_layoutPairingSuccess.visibility = View.VISIBLE
|
if (complete) {
|
||||||
_layoutPairing.visibility = View.GONE
|
_layoutPairingSuccess.visibility = View.VISIBLE
|
||||||
|
_layoutPairing.visibility = View.GONE
|
||||||
|
} else {
|
||||||
|
_textError.text = message
|
||||||
|
_layoutPairingError.visibility = View.VISIBLE
|
||||||
|
_layoutPairing.visibility = View.GONE
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
_textPairingStatus.text = message
|
_textPairingStatus.text = message
|
||||||
}
|
}
|
||||||
@@ -137,8 +144,6 @@ class SyncPairActivity : AppCompatActivity() {
|
|||||||
_textError.text = e.message
|
_textError.text = e.message
|
||||||
_layoutPairing.visibility = View.GONE
|
_layoutPairing.visibility = View.GONE
|
||||||
Logger.e(TAG, "Failed to pair", e)
|
Logger.e(TAG, "Failed to pair", e)
|
||||||
} finally {
|
|
||||||
_layoutPairing.visibility = View.GONE
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-5
@@ -67,11 +67,18 @@ class SyncShowPairingCodeActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val ips = getIPs()
|
val ips = getIPs()
|
||||||
val selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT, StateSync.instance.pairingCode)
|
val publicKey = StateSync.instance.syncService?.publicKey
|
||||||
val json = Json.encodeToString(selfDeviceInfo)
|
val pairingCode = StateSync.instance.syncService?.pairingCode
|
||||||
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
if (publicKey == null || pairingCode == null) {
|
||||||
val url = "grayjay://sync/${base64}"
|
setCode("Public key or pairing code was not known, is sync enabled?")
|
||||||
setCode(url)
|
} 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?) {
|
fun setCode(code: String?) {
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ open class ManagedHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun tryHead(url: String): Map<String, String>? {
|
fun tryHead(url: String): Map<String, String>? {
|
||||||
|
ensureNotMainThread()
|
||||||
try {
|
try {
|
||||||
val result = head(url);
|
val result = head(url);
|
||||||
if(result.isOk)
|
if(result.isOk)
|
||||||
@@ -104,7 +105,7 @@ open class ManagedHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun socket(url: String, headers: MutableMap<String, String> = HashMap(), listener: SocketListener): Socket {
|
fun socket(url: String, headers: MutableMap<String, String> = HashMap(), listener: SocketListener): Socket {
|
||||||
|
ensureNotMainThread()
|
||||||
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
|
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
|
||||||
.url(url);
|
.url(url);
|
||||||
if(user_agent.isNotEmpty() && !headers.any { it.key.lowercase() == "user-agent" })
|
if(user_agent.isNotEmpty() && !headers.any { it.key.lowercase() == "user-agent" })
|
||||||
@@ -300,6 +301,7 @@ open class ManagedHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun send(msg: String) {
|
fun send(msg: String) {
|
||||||
|
ensureNotMainThread()
|
||||||
socket.send(msg);
|
socket.send(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,16 @@ class PlatformClientPool {
|
|||||||
private var _poolCounter = 0;
|
private var _poolCounter = 0;
|
||||||
private val _poolName: String?;
|
private val _poolName: String?;
|
||||||
private val _privatePool: Boolean;
|
private val _privatePool: Boolean;
|
||||||
|
private val _isolatedInitialization: Boolean
|
||||||
|
|
||||||
var isDead: Boolean = false
|
var isDead: Boolean = false
|
||||||
private set;
|
private set;
|
||||||
val onDead = Event2<JSClient, PlatformClientPool>();
|
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;
|
_poolName = name;
|
||||||
_privatePool = privatePool;
|
_privatePool = privatePool;
|
||||||
|
_isolatedInitialization = isolatedInitialization
|
||||||
if(parentClient !is JSClient)
|
if(parentClient !is JSClient)
|
||||||
throw IllegalArgumentException("Pooling only supported for JSClients right now");
|
throw IllegalArgumentException("Pooling only supported for JSClients right now");
|
||||||
Logger.i(TAG, "Pool for ${parentClient.name} was started");
|
Logger.i(TAG, "Pool for ${parentClient.name} was started");
|
||||||
@@ -53,7 +55,7 @@ class PlatformClientPool {
|
|||||||
reserved = _pool.keys.find { !it.isBusy };
|
reserved = _pool.keys.find { !it.isBusy };
|
||||||
if(reserved == null && _pool.size < capacity) {
|
if(reserved == null && _pool.size < capacity) {
|
||||||
Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${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 ->
|
reserved?.onCaptchaException?.subscribe { client, ex ->
|
||||||
StateApp.instance.handleCaptchaException(client, ex);
|
StateApp.instance.handleCaptchaException(client, ex);
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ class PlatformMultiClientPool {
|
|||||||
|
|
||||||
private var _isFake = false;
|
private var _isFake = false;
|
||||||
private var _privatePool = 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;
|
_name = name;
|
||||||
_maxCap = if(maxCap > 0)
|
_maxCap = if(maxCap > 0)
|
||||||
maxCap
|
maxCap
|
||||||
else 99;
|
else 99;
|
||||||
_privatePool = isPrivatePool;
|
_privatePool = isPrivatePool;
|
||||||
|
_isolatedInitialization = isolatedInitialization
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient {
|
fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient {
|
||||||
@@ -21,7 +23,7 @@ class PlatformMultiClientPool {
|
|||||||
return parentClient;
|
return parentClient;
|
||||||
val pool = synchronized(_clientPools) {
|
val pool = synchronized(_clientPools) {
|
||||||
if(!_clientPools.containsKey(parentClient))
|
if(!_clientPools.containsKey(parentClient))
|
||||||
_clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool).apply {
|
_clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool, _isolatedInitialization).apply {
|
||||||
this.onDead.subscribe { _, pool ->
|
this.onDead.subscribe { _, pool ->
|
||||||
synchronized(_clientPools) {
|
synchronized(_clientPools) {
|
||||||
if(_clientPools[parentClient] == pool)
|
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?;
|
||||||
|
}
|
||||||
+12
@@ -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),
|
POST(2),
|
||||||
ARTICLE(3),
|
ARTICLE(3),
|
||||||
PLAYLIST(4),
|
PLAYLIST(4),
|
||||||
|
WEB(7),
|
||||||
|
|
||||||
URL(9),
|
URL(9),
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException
|
|||||||
enum class TextType(val value: Int) {
|
enum class TextType(val value: Int) {
|
||||||
RAW(0),
|
RAW(0),
|
||||||
HTML(1),
|
HTML(1),
|
||||||
MARKUP(2);
|
MARKUP(2),
|
||||||
|
CODE(3);
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromInt(value: Int): TextType
|
fun fromInt(value: Int): TextType
|
||||||
|
|||||||
@@ -54,8 +54,11 @@ class DevJSClient : JSClient {
|
|||||||
return DevJSClient(context, config, _devScript, _auth, _captcha, devID, descriptor.settings);
|
return DevJSClient(context, config, _devScript, _auth, _captcha, devID, descriptor.settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCopy(privateCopy: Boolean): JSClient {
|
override fun getCopy(privateCopy: Boolean, noSaveState: Boolean): JSClient {
|
||||||
return DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, saveState(), devID);
|
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() {
|
override fun initialize() {
|
||||||
|
|||||||
@@ -195,8 +195,11 @@ open class JSClient : IPlatformClient {
|
|||||||
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
|
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun getCopy(withoutCredentials: Boolean = false): JSClient {
|
open fun getCopy(withoutCredentials: Boolean = false, noSaveState: Boolean = false): JSClient {
|
||||||
return JSClient(_context, descriptor, saveState(), _script, withoutCredentials);
|
val client = JSClient(_context, descriptor, if (noSaveState) null else saveState(), _script, withoutCredentials);
|
||||||
|
if (noSaveState)
|
||||||
|
client.initialize()
|
||||||
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getUnderlyingPlugin(): V8Plugin {
|
fun getUnderlyingPlugin(): V8Plugin {
|
||||||
@@ -211,6 +214,8 @@ open class JSClient : IPlatformClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun initialize() {
|
override fun initialize() {
|
||||||
|
if (_initialized) return
|
||||||
|
|
||||||
Logger.i(TAG, "Plugin [${config.name}] initializing");
|
Logger.i(TAG, "Plugin [${config.name}] initializing");
|
||||||
plugin.start();
|
plugin.start();
|
||||||
plugin.execute("plugin.config = ${Json.encodeToString(config)}");
|
plugin.execute("plugin.config = ${Json.encodeToString(config)}");
|
||||||
|
|||||||
+7
-1
@@ -127,7 +127,7 @@ class JSHttpClient : ManagedHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(doApplyCookies) {
|
if(doApplyCookies) {
|
||||||
if (_currentCookieMap.isNotEmpty()) {
|
if (_currentCookieMap.isNotEmpty() || _otherCookieMap.isNotEmpty()) {
|
||||||
val cookiesToApply = hashMapOf<String, String>();
|
val cookiesToApply = hashMapOf<String, String>();
|
||||||
synchronized(_currentCookieMap) {
|
synchronized(_currentCookieMap) {
|
||||||
for(cookie in _currentCookieMap
|
for(cookie in _currentCookieMap
|
||||||
@@ -135,6 +135,12 @@ class JSHttpClient : ManagedHttpClient {
|
|||||||
.flatMap { it.value.toList() })
|
.flatMap { it.value.toList() })
|
||||||
cookiesToApply[cookie.first] = cookie.second;
|
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) {
|
if(cookiesToApply.size > 0) {
|
||||||
val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; ");
|
val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; ");
|
||||||
|
|||||||
+3
-1
@@ -27,7 +27,9 @@ interface IJSContent: IPlatformContent {
|
|||||||
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
|
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
|
||||||
ContentType.PLAYLIST -> JSPlaylist(config, obj);
|
ContentType.PLAYLIST -> JSPlaylist(config, obj);
|
||||||
ContentType.LOCKED -> JSLockedContent(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}");
|
else -> throw NotImplementedError("Unknown content type ${type}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -17,6 +17,7 @@ interface IJSContentDetails: IPlatformContent {
|
|||||||
ContentType.MEDIA -> JSVideoDetails(plugin, obj);
|
ContentType.MEDIA -> JSVideoDetails(plugin, obj);
|
||||||
ContentType.POST -> JSPostDetails(plugin.config, obj);
|
ContentType.POST -> JSPostDetails(plugin.config, obj);
|
||||||
ContentType.ARTICLE -> JSArticleDetails(plugin, obj);
|
ContentType.ARTICLE -> JSArticleDetails(plugin, obj);
|
||||||
|
ContentType.WEB -> JSWebDetails(plugin, obj);
|
||||||
else -> throw NotImplementedError("Unknown content type ${type}");
|
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));
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-6
@@ -4,6 +4,8 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
|||||||
import com.futo.platformplayer.api.media.IPlatformClient
|
import com.futo.platformplayer.api.media.IPlatformClient
|
||||||
import com.futo.platformplayer.api.media.IPluginSourced
|
import com.futo.platformplayer.api.media.IPluginSourced
|
||||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
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.comments.IPlatformComment
|
||||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
@@ -21,20 +23,20 @@ import com.futo.platformplayer.getOrThrow
|
|||||||
import com.futo.platformplayer.getOrThrowNullableList
|
import com.futo.platformplayer.getOrThrowNullableList
|
||||||
import com.futo.platformplayer.states.StateDeveloper
|
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;
|
final override val contentType: ContentType get() = ContentType.ARTICLE;
|
||||||
|
|
||||||
private val _hasGetComments: Boolean;
|
private val _hasGetComments: Boolean;
|
||||||
private val _hasGetContentRecommendations: Boolean;
|
private val _hasGetContentRecommendations: Boolean;
|
||||||
|
|
||||||
val rating: IRating;
|
override val rating: IRating;
|
||||||
|
|
||||||
val summary: String;
|
override val summary: String;
|
||||||
val thumbnails: Thumbnails?;
|
override val thumbnails: Thumbnails?;
|
||||||
val segments: List<IJSArticleSegment>;
|
override val segments: List<IJSArticleSegment>;
|
||||||
|
|
||||||
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
|
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);
|
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);
|
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"))) {
|
return when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
|
||||||
SegmentType.TEXT -> JSTextSegment(client, obj);
|
SegmentType.TEXT -> JSTextSegment(client, obj);
|
||||||
SegmentType.IMAGES -> JSImagesSegment(client, obj);
|
SegmentType.IMAGES -> JSImagesSegment(client, obj);
|
||||||
|
SegmentType.HEADER -> JSHeaderSegment(client, obj);
|
||||||
SegmentType.NESTED -> JSNestedSegment(client, obj);
|
SegmentType.NESTED -> JSNestedSegment(client, obj);
|
||||||
else -> null;
|
else -> null;
|
||||||
}
|
}
|
||||||
@@ -110,6 +113,7 @@ enum class SegmentType(val value: Int) {
|
|||||||
UNKNOWN(0),
|
UNKNOWN(0),
|
||||||
TEXT(1),
|
TEXT(1),
|
||||||
IMAGES(2),
|
IMAGES(2),
|
||||||
|
HEADER(3),
|
||||||
|
|
||||||
NESTED(9);
|
NESTED(9);
|
||||||
|
|
||||||
@@ -150,6 +154,17 @@ class JSImagesSegment: IJSArticleSegment {
|
|||||||
caption = obj.getOrDefault(client.config, "caption", contextName, "") ?: "";
|
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 {
|
class JSNestedSegment: IJSArticleSegment {
|
||||||
override val type = SegmentType.NESTED;
|
override val type = SegmentType.NESTED;
|
||||||
val nested: IPlatformContent;
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -149,6 +149,7 @@ class AirPlayCastingDevice : CastingDevice {
|
|||||||
break;
|
break;
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
|
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
|
||||||
|
delay(1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ abstract class CastingDevice {
|
|||||||
|
|
||||||
val expectedCurrentTime: Double
|
val expectedCurrentTime: Double
|
||||||
get() {
|
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;
|
return time + diff;
|
||||||
};
|
};
|
||||||
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
|
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import com.futo.platformplayer.toHexString
|
|||||||
import com.futo.platformplayer.toInetAddress
|
import com.futo.platformplayer.toInetAddress
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
@@ -56,6 +58,10 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
private var _mediaSessionId: Int? = null;
|
private var _mediaSessionId: Int? = null;
|
||||||
private var _thread: Thread? = null;
|
private var _thread: Thread? = null;
|
||||||
private var _pingThread: 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() {
|
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
@@ -229,6 +235,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
launchObject.put("appId", "CC1AD845");
|
launchObject.put("appId", "CC1AD845");
|
||||||
launchObject.put("requestId", _requestId++);
|
launchObject.put("requestId", _requestId++);
|
||||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
||||||
|
_lastLaunchTime_ms = System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getStatus() {
|
private fun getStatus() {
|
||||||
@@ -268,6 +275,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
_contentType = null;
|
_contentType = null;
|
||||||
_streamType = null;
|
_streamType = null;
|
||||||
_sessionId = null;
|
_sessionId = null;
|
||||||
|
_launchRetries = 0
|
||||||
_transportId = null;
|
_transportId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +290,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
|
|
||||||
_started = true;
|
_started = true;
|
||||||
_sessionId = null;
|
_sessionId = null;
|
||||||
|
_launchRetries = 0
|
||||||
_mediaSessionId = null;
|
_mediaSessionId = null;
|
||||||
|
|
||||||
Logger.i(TAG, "Starting...");
|
Logger.i(TAG, "Starting...");
|
||||||
@@ -322,6 +331,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
break;
|
break;
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
|
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
|
||||||
|
Thread.sleep(1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +344,10 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
|
|
||||||
//Connection loop
|
//Connection loop
|
||||||
while (_scopeIO?.isActive == true) {
|
while (_scopeIO?.isActive == true) {
|
||||||
|
_sessionId = null;
|
||||||
|
_launchRetries = 0
|
||||||
|
_mediaSessionId = null;
|
||||||
|
|
||||||
Logger.i(TAG, "Connecting to Chromecast.");
|
Logger.i(TAG, "Connecting to Chromecast.");
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
connectionState = CastConnectionState.CONNECTING;
|
||||||
|
|
||||||
@@ -392,7 +406,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
try {
|
try {
|
||||||
val inputStream = _inputStream ?: break;
|
val inputStream = _inputStream ?: break;
|
||||||
|
|
||||||
synchronized(_inputStreamLock)
|
val message = synchronized(_inputStreamLock)
|
||||||
{
|
{
|
||||||
Log.d(TAG, "Receiving next packet...");
|
Log.d(TAG, "Receiving next packet...");
|
||||||
val b1 = inputStream.readUnsignedByte();
|
val b1 = inputStream.readUnsignedByte();
|
||||||
@@ -404,7 +418,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
if (size > buffer.size) {
|
if (size > buffer.size) {
|
||||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
||||||
inputStream.skip(size.toLong());
|
inputStream.skip(size.toLong());
|
||||||
return@synchronized
|
return@synchronized null
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||||
@@ -413,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?
|
//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));
|
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
|
||||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||||
val message = ChromeCast.CastMessage.parseFrom(messageBytes);
|
val msg = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||||
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||||
Logger.i(TAG, "Received message: $message");
|
Logger.i(TAG, "Received message: $msg");
|
||||||
}
|
}
|
||||||
|
return@synchronized msg
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message != null) {
|
||||||
try {
|
try {
|
||||||
handleMessage(message);
|
handleMessage(message);
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Failed to handle message.", e);
|
Logger.w(TAG, "Failed to handle message.", e);
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: java.net.SocketException) {
|
} catch (e: java.net.SocketException) {
|
||||||
@@ -485,6 +503,10 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +533,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
if (_sessionId == null) {
|
if (_sessionId == null) {
|
||||||
connectionState = CastConnectionState.CONNECTED;
|
connectionState = CastConnectionState.CONNECTED;
|
||||||
_sessionId = applicationUpdate.getString("sessionId");
|
_sessionId = applicationUpdate.getString("sessionId");
|
||||||
|
_launchRetries = 0
|
||||||
|
|
||||||
val transportId = applicationUpdate.getString("transportId");
|
val transportId = applicationUpdate.getString("transportId");
|
||||||
connectMediaChannel(transportId);
|
connectMediaChannel(transportId);
|
||||||
@@ -525,21 +548,40 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionIsRunning) {
|
if (!sessionIsRunning) {
|
||||||
_sessionId = null;
|
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
|
||||||
_mediaSessionId = null;
|
_sessionId = null
|
||||||
setTime(0.0);
|
_mediaSessionId = null
|
||||||
_transportId = null;
|
setTime(0.0)
|
||||||
Logger.w(TAG, "Session not found.");
|
_transportId = null
|
||||||
|
|
||||||
if (_launching) {
|
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||||
Logger.i(TAG, "Player not found, launching.");
|
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
|
||||||
launchPlayer();
|
_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 {
|
} else {
|
||||||
Logger.i(TAG, "Player not found, disconnecting.");
|
if (_retryJob == null) {
|
||||||
stop();
|
Logger.i(TAG, "Scheduled retry job over 5 seconds")
|
||||||
|
_retryJob = _scopeIO?.launch(Dispatchers.IO) {
|
||||||
|
delay(5000)
|
||||||
|
getStatus()
|
||||||
|
_retryJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_launching = false;
|
_launching = false
|
||||||
|
_launchRetries = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
val volume = status.getJSONObject("volume");
|
val volume = status.getJSONObject("volume");
|
||||||
@@ -566,7 +608,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isPlaying = playerState == "PLAYING";
|
isPlaying = playerState == "PLAYING";
|
||||||
if (isPlaying) {
|
if (isPlaying || playerState == "PAUSED") {
|
||||||
setTime(currentTime);
|
setTime(currentTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,6 +623,8 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
if (message.sourceId == "receiver-0") {
|
if (message.sourceId == "receiver-0") {
|
||||||
Logger.i(TAG, "Close received.");
|
Logger.i(TAG, "Close received.");
|
||||||
stop();
|
stop();
|
||||||
|
} else if (_transportId == message.sourceId) {
|
||||||
|
throw Exception("Transport id closed.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -615,6 +659,9 @@ class ChromecastCastingDevice : CastingDevice {
|
|||||||
localAddress = null;
|
localAddress = null;
|
||||||
_started = false;
|
_started = false;
|
||||||
|
|
||||||
|
_retryJob?.cancel()
|
||||||
|
_retryJob = null
|
||||||
|
|
||||||
val socket = _socket;
|
val socket = _socket;
|
||||||
val scopeIO = _scopeIO;
|
val scopeIO = _scopeIO;
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import com.futo.platformplayer.toInetAddress
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
@@ -289,6 +290,7 @@ class FCastCastingDevice : CastingDevice {
|
|||||||
break;
|
break;
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
|
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
|
||||||
|
Thread.sleep(1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import android.os.Build
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
import java.net.Inet4Address
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.futo.platformplayer.R
|
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.Event1
|
||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||||
|
import com.futo.platformplayer.findPreferredAddress
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
import com.futo.platformplayer.parsers.HLS
|
import com.futo.platformplayer.parsers.HLS
|
||||||
import com.futo.platformplayer.states.StateApp
|
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.CastingDeviceInfoStorage
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.futo.platformplayer.toUrlAddress
|
import com.futo.platformplayer.toUrlAddress
|
||||||
@@ -57,9 +58,11 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.net.Inet6Address
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
import java.util.Collections
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class StateCasting {
|
class StateCasting {
|
||||||
@@ -485,7 +488,7 @@ class StateCasting {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests;
|
val proxyStreams = Settings.instance.casting.alwaysProxyRequests;
|
||||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
val url = getLocalUrl(ad);
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
|
|
||||||
if (videoSource is IVideoUrlSource) {
|
if (videoSource is IVideoUrlSource) {
|
||||||
@@ -580,7 +583,7 @@ class StateCasting {
|
|||||||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
val url = getLocalUrl(ad);
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
val videoPath = "/video-${id}"
|
val videoPath = "/video-${id}"
|
||||||
val videoUrl = url + videoPath;
|
val videoUrl = url + videoPath;
|
||||||
@@ -599,7 +602,7 @@ class StateCasting {
|
|||||||
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> {
|
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
val url = getLocalUrl(ad);
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
val audioPath = "/audio-${id}"
|
val audioPath = "/audio-${id}"
|
||||||
val audioUrl = url + audioPath;
|
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> {
|
private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List<String> {
|
||||||
val ad = activeDevice ?: return listOf()
|
val ad = activeDevice ?: return listOf()
|
||||||
|
|
||||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"
|
val url = getLocalUrl(ad)
|
||||||
val id = UUID.randomUUID()
|
val id = UUID.randomUUID()
|
||||||
|
|
||||||
val hlsPath = "/hls-${id}"
|
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> {
|
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
val url = getLocalUrl(ad);
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
|
|
||||||
val dashPath = "/dash-${id}"
|
val dashPath = "/dash-${id}"
|
||||||
@@ -764,7 +767,7 @@ class StateCasting {
|
|||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
|
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 id = UUID.randomUUID();
|
||||||
|
|
||||||
val videoPath = "/video-${id}"
|
val videoPath = "/video-${id}"
|
||||||
@@ -829,7 +832,7 @@ class StateCasting {
|
|||||||
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
||||||
|
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
val url = getLocalUrl(ad);
|
||||||
|
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
val hlsPath = "/hls-${id}"
|
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> {
|
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 ad = activeDevice ?: return listOf();
|
||||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
val url = getLocalUrl(ad);
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
|
|
||||||
val hlsPath = "/hls-${id}"
|
val hlsPath = "/hls-${id}"
|
||||||
@@ -1129,7 +1132,7 @@ class StateCasting {
|
|||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
|
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 id = UUID.randomUUID();
|
||||||
|
|
||||||
val dashPath = "/dash-${id}"
|
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)
|
@OptIn(UnstableApi::class)
|
||||||
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
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();
|
val ad = activeDevice ?: return listOf();
|
||||||
@@ -1222,7 +1234,7 @@ class StateCasting {
|
|||||||
cleanExecutors()
|
cleanExecutors()
|
||||||
_castServer.removeAllHandlers("castDashRaw")
|
_castServer.removeAllHandlers("castDashRaw")
|
||||||
|
|
||||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
val url = getLocalUrl(ad);
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
|
|
||||||
val dashPath = "/dash-${id}"
|
val dashPath = "/dash-${id}"
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import android.app.PendingIntent.getBroadcast
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageInstaller
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.content.pm.PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED
|
||||||
import android.graphics.drawable.Animatable
|
import android.graphics.drawable.Animatable
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -155,6 +157,9 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||||||
|
|
||||||
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller;
|
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller;
|
||||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL);
|
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);
|
val sessionId = packageInstaller.createSession(params);
|
||||||
session = packageInstaller.openSession(sessionId)
|
session = packageInstaller.openSession(sessionId)
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import com.futo.platformplayer.states.StatePlatform
|
|||||||
import com.futo.platformplayer.states.StatePlugins
|
import com.futo.platformplayer.states.StatePlugins
|
||||||
import com.futo.platformplayer.toHumanBitrate
|
import com.futo.platformplayer.toHumanBitrate
|
||||||
import com.futo.platformplayer.toHumanBytesSpeed
|
import com.futo.platformplayer.toHumanBytesSpeed
|
||||||
|
import com.futo.polycentric.core.hexStringToByteArray
|
||||||
import hasAnySource
|
import hasAnySource
|
||||||
import isDownloadable
|
import isDownloadable
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
@@ -59,16 +60,21 @@ import kotlinx.coroutines.suspendCancellableCoroutine
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Contextual
|
import kotlinx.serialization.Contextual
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.lang.Thread.sleep
|
import java.lang.Thread.sleep
|
||||||
|
import java.nio.ByteBuffer
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.ForkJoinPool
|
import java.util.concurrent.ForkJoinPool
|
||||||
import java.util.concurrent.ForkJoinTask
|
import java.util.concurrent.ForkJoinTask
|
||||||
import java.util.concurrent.ThreadLocalRandom
|
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.coroutines.resumeWithException
|
||||||
import kotlin.time.times
|
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 {
|
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||||
if(targetFile.exists())
|
if(targetFile.exists())
|
||||||
targetFile.delete();
|
targetFile.delete();
|
||||||
@@ -579,6 +593,14 @@ class VideoDownload {
|
|||||||
?: throw Exception("Variant playlist content is empty")
|
?: throw Exception("Variant playlist content is empty")
|
||||||
|
|
||||||
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
|
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 ->
|
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||||
if (segment !is HLS.MediaSegment) {
|
if (segment !is HLS.MediaSegment) {
|
||||||
return@forEachIndexed
|
return@forEachIndexed
|
||||||
@@ -590,7 +612,7 @@ class VideoDownload {
|
|||||||
try {
|
try {
|
||||||
segmentFiles.add(segmentFile)
|
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 averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
|
||||||
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
|
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
|
||||||
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
|
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) {
|
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
|
||||||
suspendCancellableCoroutine { continuation ->
|
suspendCancellableCoroutine { continuation ->
|
||||||
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
|
val cmd =
|
||||||
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
|
"-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
|
||||||
|
|
||||||
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
|
|
||||||
|
|
||||||
val statisticsCallback = StatisticsCallback { _ ->
|
val statisticsCallback = StatisticsCallback { _ ->
|
||||||
//TODO: Show progress?
|
//TODO: Show progress?
|
||||||
@@ -643,7 +663,6 @@ class VideoDownload {
|
|||||||
val session = FFmpegKit.executeAsync(cmd,
|
val session = FFmpegKit.executeAsync(cmd,
|
||||||
{ session ->
|
{ session ->
|
||||||
if (ReturnCode.isSuccess(session.returnCode)) {
|
if (ReturnCode.isSuccess(session.returnCode)) {
|
||||||
fileList.delete()
|
|
||||||
continuation.resumeWith(Result.success(Unit))
|
continuation.resumeWith(Result.success(Unit))
|
||||||
} else {
|
} else {
|
||||||
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
||||||
@@ -651,7 +670,6 @@ class VideoDownload {
|
|||||||
} else {
|
} else {
|
||||||
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
||||||
}
|
}
|
||||||
fileList.delete()
|
|
||||||
continuation.resumeWithException(RuntimeException(errorMessage))
|
continuation.resumeWithException(RuntimeException(errorMessage))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -771,7 +789,7 @@ class VideoDownload {
|
|||||||
else {
|
else {
|
||||||
Logger.i(TAG, "Download $name Sequential");
|
Logger.i(TAG, "Download $name Sequential");
|
||||||
try {
|
try {
|
||||||
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, onProgress);
|
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, 0, onProgress);
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
|
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
|
||||||
throw e
|
throw e
|
||||||
@@ -798,7 +816,31 @@ class VideoDownload {
|
|||||||
}
|
}
|
||||||
return sourceLength!!;
|
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;
|
val progressRate: Int = 4096 * 5;
|
||||||
var lastProgressCount: Int = 0;
|
var lastProgressCount: Int = 0;
|
||||||
val speedRate: Int = 4096 * 5;
|
val speedRate: Int = 4096 * 5;
|
||||||
@@ -818,6 +860,8 @@ class VideoDownload {
|
|||||||
val sourceLength = result.body.contentLength();
|
val sourceLength = result.body.contentLength();
|
||||||
val sourceStream = result.body.byteStream();
|
val sourceStream = result.body.byteStream();
|
||||||
|
|
||||||
|
val segmentBuffer = ByteArrayOutputStream()
|
||||||
|
|
||||||
var totalRead: Long = 0;
|
var totalRead: Long = 0;
|
||||||
try {
|
try {
|
||||||
var read: Int;
|
var read: Int;
|
||||||
@@ -828,7 +872,7 @@ class VideoDownload {
|
|||||||
if (read < 0)
|
if (read < 0)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
fileStream.write(buffer, 0, read);
|
segmentBuffer.write(buffer, 0, read);
|
||||||
|
|
||||||
totalRead += read;
|
totalRead += read;
|
||||||
|
|
||||||
@@ -854,6 +898,21 @@ class VideoDownload {
|
|||||||
result.body.close()
|
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);
|
onProgress(sourceLength, totalRead, 0);
|
||||||
return sourceLength;
|
return sourceLength;
|
||||||
}
|
}
|
||||||
@@ -1160,6 +1219,8 @@ class VideoDownload {
|
|||||||
fun audioContainerToExtension(container: String): String {
|
fun audioContainerToExtension(container: String): String {
|
||||||
if (container.contains("audio/mp4"))
|
if (container.contains("audio/mp4"))
|
||||||
return "mp4a";
|
return "mp4a";
|
||||||
|
else if (container.contains("video/mp4"))
|
||||||
|
return "mp4";
|
||||||
else if (container.contains("audio/mpeg"))
|
else if (container.contains("audio/mpeg"))
|
||||||
return "mpga";
|
return "mpga";
|
||||||
else if (container.contains("audio/mp3"))
|
else if (container.contains("audio/mp3"))
|
||||||
@@ -1167,7 +1228,7 @@ class VideoDownload {
|
|||||||
else if (container.contains("audio/webm"))
|
else if (container.contains("audio/webm"))
|
||||||
return "webm";
|
return "webm";
|
||||||
else if (container == "application/vnd.apple.mpegurl")
|
else if (container == "application/vnd.apple.mpegurl")
|
||||||
return "mp4a";
|
return "m4a";
|
||||||
else
|
else
|
||||||
return "audio";// throw IllegalStateException("Unknown container: " + container)
|
return "audio";// throw IllegalStateException("Unknown container: " + container)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class VideoExport {
|
|||||||
outputFile = f;
|
outputFile = f;
|
||||||
} else if (v != null) {
|
} else if (v != null) {
|
||||||
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container);
|
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.");
|
?: throw Exception("Failed to create file in external directory.");
|
||||||
|
|
||||||
Logger.i(TAG, "Copying video.");
|
Logger.i(TAG, "Copying video.");
|
||||||
@@ -81,8 +81,8 @@ class VideoExport {
|
|||||||
outputFile = f;
|
outputFile = f;
|
||||||
} else if (a != null) {
|
} else if (a != null) {
|
||||||
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container);
|
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container);
|
||||||
val f = downloadRoot.createFile(a.container, outputFileName)
|
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.");
|
?: throw Exception("Failed to create file in external directory.");
|
||||||
|
|
||||||
Logger.i(TAG, "Copying audio.");
|
Logger.i(TAG, "Copying audio.");
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,14 @@ class V8Plugin {
|
|||||||
whenNotBusy {
|
whenNotBusy {
|
||||||
synchronized(_runtimeLock) {
|
synchronized(_runtimeLock) {
|
||||||
isStopped = true;
|
isStopped = true;
|
||||||
|
|
||||||
|
//Cleanup http
|
||||||
|
for(pack in _depsPackages) {
|
||||||
|
if(pack is PackageHttp) {
|
||||||
|
pack.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_runtime?.let {
|
_runtime?.let {
|
||||||
_runtime = null;
|
_runtime = null;
|
||||||
if(!it.isClosed && !it.isDead) {
|
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.states.StateDeveloper
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
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.JSClient
|
||||||
import com.futo.platformplayer.api.media.platforms.js.JSClientConstants
|
import com.futo.platformplayer.api.media.platforms.js.JSClientConstants
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
@@ -77,6 +78,22 @@ class PackageBridge : V8Package {
|
|||||||
return "android";
|
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
|
@V8Function
|
||||||
fun dispose(value: V8Value) {
|
fun dispose(value: V8Value) {
|
||||||
Logger.e(TAG, "Manual dispose: " + value.javaClass.name);
|
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.interop.V8Runtime
|
||||||
import com.caoccao.javet.values.V8Value
|
import com.caoccao.javet.values.V8Value
|
||||||
import com.caoccao.javet.values.primitive.V8ValueString
|
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.V8ValueObject
|
||||||
import com.caoccao.javet.values.reference.V8ValueSharedArrayBuffer
|
|
||||||
import com.caoccao.javet.values.reference.V8ValueTypedArray
|
import com.caoccao.javet.values.reference.V8ValueTypedArray
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
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.IV8Convertable
|
||||||
import com.futo.platformplayer.engine.internal.V8BindObject
|
import com.futo.platformplayer.engine.internal.V8BindObject
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateApp
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.net.SocketTimeoutException
|
import java.net.SocketTimeoutException
|
||||||
import java.util.concurrent.ForkJoinPool
|
import java.util.concurrent.ForkJoinPool
|
||||||
import java.util.concurrent.ForkJoinTask
|
import java.util.concurrent.ForkJoinTask
|
||||||
import kotlin.concurrent.thread
|
|
||||||
import kotlin.streams.asSequence
|
|
||||||
|
|
||||||
class PackageHttp: V8Package {
|
class PackageHttp: V8Package {
|
||||||
@Transient
|
@Transient
|
||||||
@@ -49,6 +41,9 @@ class PackageHttp: V8Package {
|
|||||||
private var _batchPoolLock: Any = Any();
|
private var _batchPoolLock: Any = Any();
|
||||||
private var _batchPool: ForkJoinPool? = null;
|
private var _batchPool: ForkJoinPool? = null;
|
||||||
|
|
||||||
|
private val aliveSockets = mutableListOf<SocketResult>();
|
||||||
|
private var _cleanedUp = false;
|
||||||
|
|
||||||
|
|
||||||
constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) {
|
constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) {
|
||||||
_config = config;
|
_config = config;
|
||||||
@@ -58,6 +53,27 @@ class PackageHttp: V8Package {
|
|||||||
_packageClientAuth = PackageHttpClient(this, _clientAuth);
|
_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.
|
Automatically adjusting threadpool dedicated per PackageHttp for batch requests.
|
||||||
@@ -111,24 +127,24 @@ class PackageHttp: V8Package {
|
|||||||
@V8Function
|
@V8Function
|
||||||
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
|
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
|
||||||
return if(useAuth)
|
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
|
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
|
@V8Function
|
||||||
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
|
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
|
||||||
return if(useAuth)
|
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
|
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
|
@V8Function
|
||||||
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse {
|
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse {
|
||||||
return if(useAuth)
|
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
|
else
|
||||||
_packageClient.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
|
_packageClient.GETInternal(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
|
||||||
}
|
}
|
||||||
@V8Function
|
@V8Function
|
||||||
fun POST(url: String, body: Any, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse {
|
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;
|
val client = if(useAuth) _packageClientAuth else _packageClient;
|
||||||
|
|
||||||
if(body is V8ValueString)
|
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)
|
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)
|
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)
|
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
|
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
|
else
|
||||||
throw NotImplementedError("Body type " + body?.javaClass?.name?.toString() + " not implemented for POST");
|
throw NotImplementedError("Body type " + body?.javaClass?.name?.toString() + " not implemented for POST");
|
||||||
}
|
}
|
||||||
@@ -276,9 +292,9 @@ class PackageHttp: V8Package {
|
|||||||
if(it.second.method == "DUMMY")
|
if(it.second.method == "DUMMY")
|
||||||
return@autoParallelPool null;
|
return@autoParallelPool null;
|
||||||
if(it.second.body != 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
|
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 {
|
}.map {
|
||||||
if(it.second != null)
|
if(it.second != null)
|
||||||
throw it.second!!;
|
throw it.second!!;
|
||||||
@@ -345,7 +361,9 @@ class PackageHttp: V8Package {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@V8Function
|
@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);
|
applyDefaultHeaders(headers);
|
||||||
return logExceptions {
|
return logExceptions {
|
||||||
return@logExceptions catchHttp {
|
return@logExceptions catchHttp {
|
||||||
@@ -364,7 +382,9 @@ class PackageHttp: V8Package {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
@V8Function
|
@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);
|
applyDefaultHeaders(headers);
|
||||||
return logExceptions {
|
return logExceptions {
|
||||||
catchHttp {
|
catchHttp {
|
||||||
@@ -385,7 +405,9 @@ class PackageHttp: V8Package {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@V8Function
|
@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);
|
applyDefaultHeaders(headers);
|
||||||
return logExceptions {
|
return logExceptions {
|
||||||
catchHttp {
|
catchHttp {
|
||||||
@@ -407,7 +429,9 @@ class PackageHttp: V8Package {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
@V8Function
|
@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);
|
applyDefaultHeaders(headers);
|
||||||
return logExceptions {
|
return logExceptions {
|
||||||
catchHttp {
|
catchHttp {
|
||||||
@@ -429,7 +453,9 @@ class PackageHttp: V8Package {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
@V8Function
|
@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);
|
applyDefaultHeaders(headers);
|
||||||
return logExceptions {
|
return logExceptions {
|
||||||
catchHttp {
|
catchHttp {
|
||||||
@@ -453,9 +479,16 @@ class PackageHttp: V8Package {
|
|||||||
|
|
||||||
@V8Function
|
@V8Function
|
||||||
fun socket(url: String, headers: Map<String, String>? = null): SocketResult {
|
fun socket(url: String, headers: Map<String, String>? = null): SocketResult {
|
||||||
|
if(_package._cleanedUp)
|
||||||
|
throw IllegalStateException("Plugin shutdown");
|
||||||
val socketHeaders = headers?.toMutableMap() ?: HashMap();
|
val socketHeaders = headers?.toMutableMap() ?: HashMap();
|
||||||
applyDefaultHeaders(socketHeaders);
|
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>) {
|
private fun applyDefaultHeaders(headerMap: MutableMap<String, String>) {
|
||||||
@@ -561,13 +594,15 @@ class PackageHttp: V8Package {
|
|||||||
|
|
||||||
private var _listeners: V8ValueObject? = null;
|
private var _listeners: V8ValueObject? = null;
|
||||||
|
|
||||||
|
private val _package: PackageHttp;
|
||||||
private val _packageClient: PackageHttpClient;
|
private val _packageClient: PackageHttpClient;
|
||||||
private val _client: ManagedHttpClient;
|
private val _client: ManagedHttpClient;
|
||||||
private val _url: String;
|
private val _url: String;
|
||||||
private val _headers: Map<String, 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;
|
_packageClient = pack;
|
||||||
|
_package = parent;
|
||||||
_client = client;
|
_client = client;
|
||||||
_url = url;
|
_url = url;
|
||||||
_headers = headers;
|
_headers = headers;
|
||||||
@@ -593,7 +628,7 @@ class PackageHttp: V8Package {
|
|||||||
override fun open() {
|
override fun open() {
|
||||||
Logger.i(TAG, "Websocket opened: " + _url);
|
Logger.i(TAG, "Websocket opened: " + _url);
|
||||||
_isOpen = true;
|
_isOpen = true;
|
||||||
if(hasOpen) {
|
if(hasOpen && _listeners?.isClosed != true) {
|
||||||
try {
|
try {
|
||||||
_listeners?.invokeVoid("open", arrayOf<Any>());
|
_listeners?.invokeVoid("open", arrayOf<Any>());
|
||||||
}
|
}
|
||||||
@@ -603,7 +638,7 @@ class PackageHttp: V8Package {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
override fun message(msg: String) {
|
override fun message(msg: String) {
|
||||||
if(hasMessage) {
|
if(hasMessage && _listeners?.isClosed != true) {
|
||||||
try {
|
try {
|
||||||
_listeners?.invokeVoid("message", msg);
|
_listeners?.invokeVoid("message", msg);
|
||||||
}
|
}
|
||||||
@@ -611,7 +646,7 @@ class PackageHttp: V8Package {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
override fun closing(code: Int, reason: String) {
|
override fun closing(code: Int, reason: String) {
|
||||||
if(hasClosing)
|
if(hasClosing && _listeners?.isClosed != true)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
_listeners?.invokeVoid("closing", code, reason);
|
_listeners?.invokeVoid("closing", code, reason);
|
||||||
@@ -623,7 +658,7 @@ class PackageHttp: V8Package {
|
|||||||
}
|
}
|
||||||
override fun closed(code: Int, reason: String) {
|
override fun closed(code: Int, reason: String) {
|
||||||
_isOpen = false;
|
_isOpen = false;
|
||||||
if(hasClosed) {
|
if(hasClosed && _listeners?.isClosed != true) {
|
||||||
try {
|
try {
|
||||||
_listeners?.invokeVoid("closed", code, reason);
|
_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.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) {
|
override fun failure(exception: Throwable) {
|
||||||
_isOpen = false;
|
_isOpen = false;
|
||||||
Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception);
|
Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception);
|
||||||
if(hasFailure) {
|
if(hasFailure && _listeners?.isClosed != true) {
|
||||||
try {
|
try {
|
||||||
_listeners?.invokeVoid("failure", exception.message);
|
_listeners?.invokeVoid("failure", exception.message);
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-10
@@ -5,6 +5,7 @@ import android.os.Bundle
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
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.Event1
|
||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
import com.futo.platformplayer.constructs.TaskHandler
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
|
import com.futo.platformplayer.dp
|
||||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||||
import com.futo.platformplayer.exceptions.ChannelException
|
import com.futo.platformplayer.exceptions.ChannelException
|
||||||
@@ -32,9 +34,11 @@ import com.futo.platformplayer.fragment.mainactivity.main.FeedView
|
|||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateCache
|
import com.futo.platformplayer.states.StateCache
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
|
import com.futo.platformplayer.states.StatePlugins
|
||||||
import com.futo.platformplayer.states.StatePolycentric
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
import com.futo.platformplayer.states.StateSubscriptions
|
import com.futo.platformplayer.states.StateSubscriptions
|
||||||
import com.futo.platformplayer.views.FeedStyle
|
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.ContentPreviewViewHolder
|
||||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
|
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
|
||||||
@@ -54,6 +58,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||||||
private var _results: ArrayList<IPlatformContent> = arrayListOf();
|
private var _results: ArrayList<IPlatformContent> = arrayListOf();
|
||||||
private var _adapterResults: InsertedViewAdapterWithLoader<ContentPreviewViewHolder>? = null;
|
private var _adapterResults: InsertedViewAdapterWithLoader<ContentPreviewViewHolder>? = null;
|
||||||
private var _lastPolycentricProfile: PolycentricProfile? = null;
|
private var _lastPolycentricProfile: PolycentricProfile? = null;
|
||||||
|
private var _query: String? = null
|
||||||
|
private var _searchView: SearchView? = null
|
||||||
|
|
||||||
val onContentClicked = Event2<IPlatformContent, Long>();
|
val onContentClicked = Event2<IPlatformContent, Long>();
|
||||||
val onContentUrlClicked = Event2<String, ContentType>();
|
val onContentUrlClicked = Event2<String, ContentType>();
|
||||||
@@ -68,16 +74,32 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||||||
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
|
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
|
||||||
Logger.i(TAG, "getContentPager");
|
Logger.i(TAG, "getContentPager");
|
||||||
|
|
||||||
val lastPolycentricProfile = _lastPolycentricProfile;
|
var pager: IPager<IPlatformContent>? = null
|
||||||
var pager: IPager<IPlatformContent>? = null;
|
val query = _query
|
||||||
if (lastPolycentricProfile != null)
|
if (!query.isNullOrBlank()) {
|
||||||
pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile);
|
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(pager == null) {
|
||||||
if(subType != null)
|
if(subType != null) {
|
||||||
pager = StatePlatform.instance.getChannelContent(channel.url, subType);
|
pager = StatePlatform.instance.getChannelContent(channel.url, subType);
|
||||||
else
|
Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url}, subType = ${subType})")
|
||||||
pager = StatePlatform.instance.getChannelContent(channel.url);
|
} else {
|
||||||
|
pager = StatePlatform.instance.getChannelContent(channel.url);
|
||||||
|
Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url})")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return pager;
|
return pager;
|
||||||
}
|
}
|
||||||
@@ -144,19 +166,49 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||||||
|
|
||||||
_taskLoadVideos.cancel();
|
_taskLoadVideos.cancel();
|
||||||
|
|
||||||
|
_query = null
|
||||||
_channel = channel;
|
_channel = channel;
|
||||||
|
updateSearchViewVisibility()
|
||||||
_results.clear();
|
_results.clear();
|
||||||
_adapterResults?.notifyDataSetChanged();
|
_adapterResults?.notifyDataSetChanged();
|
||||||
|
|
||||||
loadInitial();
|
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? {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||||
val view = inflater.inflate(R.layout.fragment_channel_videos, container, false);
|
val view = inflater.inflate(R.layout.fragment_channel_videos, container, false);
|
||||||
|
|
||||||
|
_query = null
|
||||||
_recyclerResults = view.findViewById(R.id.recycler_videos);
|
_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.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
|
||||||
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
|
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
|
||||||
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
|
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
|
||||||
@@ -173,6 +225,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||||||
_recyclerResults?.layoutManager = _glmVideo;
|
_recyclerResults?.layoutManager = _glmVideo;
|
||||||
_recyclerResults?.addOnScrollListener(_scrollListener);
|
_recyclerResults?.addOnScrollListener(_scrollListener);
|
||||||
|
|
||||||
|
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +234,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||||||
_recyclerResults?.removeOnScrollListener(_scrollListener);
|
_recyclerResults?.removeOnScrollListener(_scrollListener);
|
||||||
_recyclerResults = null;
|
_recyclerResults = null;
|
||||||
_pager = null;
|
_pager = null;
|
||||||
|
_query = null
|
||||||
|
_searchView = null
|
||||||
|
|
||||||
_taskLoadVideos.cancel();
|
_taskLoadVideos.cancel();
|
||||||
_nextPageHandler.cancel();
|
_nextPageHandler.cancel();
|
||||||
@@ -303,6 +358,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun loadInitial() {
|
private fun loadInitial() {
|
||||||
|
Logger.i(TAG, "loadInitial")
|
||||||
val channel: IPlatformChannel = _channel ?: return;
|
val channel: IPlatformChannel = _channel ?: return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
_taskLoadVideos.run(channel);
|
_taskLoadVideos.run(channel);
|
||||||
|
|||||||
+12
-12
@@ -330,7 +330,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!StatePayment.instance.hasPaid) {
|
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
|
//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.scrollToTop(false)
|
||||||
currentMain.reloadFeed()
|
currentMain.reloadFeed()
|
||||||
} else {
|
} 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(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>() }),
|
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>() }),
|
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>() }),
|
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>() }),
|
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>() }),
|
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>() }),
|
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>() }),
|
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>() }),
|
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 }, {
|
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
|
||||||
val c = it.context ?: return@ButtonDefinition;
|
val c = it.context ?: return@ButtonDefinition;
|
||||||
Logger.i(TAG, "settings preventPictureInPicture()");
|
Logger.i(TAG, "settings preventPictureInPicture()");
|
||||||
@@ -416,7 +416,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
|||||||
}, UIDialogs.ActionStyle.PRIMARY));
|
}, UIDialogs.ActionStyle.PRIMARY));
|
||||||
}),
|
}),
|
||||||
ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, {
|
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
|
//96 is reserved for privacy button
|
||||||
//98 is reserved for buy button
|
//98 is reserved for buy button
|
||||||
|
|||||||
+814
@@ -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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-9
@@ -47,6 +47,7 @@ import com.futo.platformplayer.selectHighestResolutionImage
|
|||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.states.StatePlaylists
|
import com.futo.platformplayer.states.StatePlaylists
|
||||||
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
import com.futo.platformplayer.states.StateSubscriptions
|
import com.futo.platformplayer.states.StateSubscriptions
|
||||||
import com.futo.platformplayer.toHumanNumber
|
import com.futo.platformplayer.toHumanNumber
|
||||||
import com.futo.platformplayer.views.adapters.ChannelTab
|
import com.futo.platformplayer.views.adapters.ChannelTab
|
||||||
@@ -135,6 +136,8 @@ class ChannelFragment : MainFragment() {
|
|||||||
inflater.inflate(R.layout.fragment_channel, this)
|
inflater.inflate(R.layout.fragment_channel, this)
|
||||||
_taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>({ fragment.lifecycleScope },
|
_taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>({ fragment.lifecycleScope },
|
||||||
{ id ->
|
{ 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!!)
|
return@TaskHandler ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, id.claimFieldType.toLong(), id.claimType.toLong(), id.value!!)
|
||||||
}).success { setPolycentricProfile(it, animate = true) }.exception<Throwable> {
|
}).success { setPolycentricProfile(it, animate = true) }.exception<Throwable> {
|
||||||
Logger.w(TAG, "Failed to load polycentric profile.", it)
|
Logger.w(TAG, "Failed to load polycentric profile.", it)
|
||||||
@@ -422,17 +425,15 @@ class ChannelFragment : MainFragment() {
|
|||||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url)
|
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (plugin != null && plugin.capabilities.hasSearchChannelContents) {
|
buttons.add(Pair(R.drawable.ic_search) {
|
||||||
buttons.add(Pair(R.drawable.ic_search) {
|
_fragment.navigate<SuggestionsFragment>(
|
||||||
_fragment.navigate<SuggestionsFragment>(
|
SuggestionsFragmentData(
|
||||||
SuggestionsFragmentData(
|
"", SearchType.VIDEO
|
||||||
"", SearchType.VIDEO, channel.url
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
})
|
)
|
||||||
|
})
|
||||||
|
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
|
||||||
|
|
||||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
|
|
||||||
}
|
|
||||||
if(plugin != null && plugin.capabilities.hasGetChannelCapabilities) {
|
if(plugin != null && plugin.capabilities.hasGetChannelCapabilities) {
|
||||||
if(plugin.getChannelCapabilities()?.types?.contains(ResultCapabilities.TYPE_SHORTS) ?: false &&
|
if(plugin.getChannelCapabilities()?.types?.contains(ResultCapabilities.TYPE_SHORTS) ?: false &&
|
||||||
!(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(ChannelTab.SHORTS.ordinal.toLong())) {
|
!(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(ChannelTab.SHORTS.ordinal.toLong())) {
|
||||||
|
|||||||
+9
@@ -10,12 +10,14 @@ import com.futo.platformplayer.R
|
|||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.UISlideOverlays
|
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.ContentType
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
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.playlists.IPlatformPlaylist
|
||||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
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.IPlatformVideo
|
||||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
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.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateMeta
|
import com.futo.platformplayer.states.StateMeta
|
||||||
@@ -196,7 +198,14 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
|||||||
fragment.navigate<RemotePlaylistFragment>(content);
|
fragment.navigate<RemotePlaylistFragment>(content);
|
||||||
} else if (content is IPlatformPost) {
|
} else if (content is IPlatformPost) {
|
||||||
fragment.navigate<PostDetailFragment>(content);
|
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) {
|
protected open fun onContentUrlClicked(url: String, contentType: ContentType) {
|
||||||
when(contentType) {
|
when(contentType) {
|
||||||
|
|||||||
+8
-28
@@ -89,7 +89,6 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||||||
private var _sortBy: String? = null;
|
private var _sortBy: String? = null;
|
||||||
private var _filterValues: HashMap<String, List<String>> = hashMapOf();
|
private var _filterValues: HashMap<String, List<String>> = hashMapOf();
|
||||||
private var _enabledClientIds: List<String>? = null;
|
private var _enabledClientIds: List<String>? = null;
|
||||||
private var _channelUrl: String? = null;
|
|
||||||
private var _searchType: SearchType? = null;
|
private var _searchType: SearchType? = null;
|
||||||
|
|
||||||
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
|
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
|
||||||
@@ -98,17 +97,12 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||||||
constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
||||||
_taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query ->
|
_taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query ->
|
||||||
Logger.i(TAG, "Searching for: $query")
|
Logger.i(TAG, "Searching for: $query")
|
||||||
val channelUrl = _channelUrl;
|
when (_searchType)
|
||||||
if (channelUrl != null) {
|
{
|
||||||
StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds)
|
SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
|
||||||
} else {
|
SearchType.CREATOR -> StatePlatform.instance.searchChannelsAsContent(query)
|
||||||
when (_searchType)
|
SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query)
|
||||||
{
|
else -> throw Exception("Search type must be specified")
|
||||||
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> { }
|
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
|
||||||
@@ -147,7 +141,6 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||||||
fun onShown(parameter: Any?) {
|
fun onShown(parameter: Any?) {
|
||||||
if(parameter is SuggestionsFragmentData) {
|
if(parameter is SuggestionsFragmentData) {
|
||||||
setQuery(parameter.query, false);
|
setQuery(parameter.query, false);
|
||||||
setChannelUrl(parameter.channelUrl, false);
|
|
||||||
setSearchType(parameter.searchType, false)
|
setSearchType(parameter.searchType, false)
|
||||||
|
|
||||||
fragment.topBar?.apply {
|
fragment.topBar?.apply {
|
||||||
@@ -164,7 +157,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||||||
onFilterClick.subscribe(this) {
|
onFilterClick.subscribe(this) {
|
||||||
_overlayContainer.let {
|
_overlayContainer.let {
|
||||||
val filterValuesCopy = HashMap(_filterValues);
|
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 ->
|
filtersOverlay.onOK.subscribe { enabledClientIds, changed ->
|
||||||
if (changed) {
|
if (changed) {
|
||||||
setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy);
|
setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy);
|
||||||
@@ -211,11 +204,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||||||
|
|
||||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val commonCapabilities =
|
val commonCapabilities = StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
|
||||||
if(_channelUrl == null)
|
|
||||||
StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
|
|
||||||
else
|
|
||||||
StatePlatform.instance.getCommonSearchChannelContentsCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
|
|
||||||
val sorts = commonCapabilities?.sorts ?: listOf();
|
val sorts = commonCapabilities?.sorts ?: listOf();
|
||||||
if (sorts.size > 1) {
|
if (sorts.size > 1) {
|
||||||
withContext(Dispatchers.Main) {
|
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) {
|
private fun setSearchType(searchType: SearchType, updateResults: Boolean = true) {
|
||||||
_searchType = searchType
|
_searchType = searchType
|
||||||
|
|
||||||
|
|||||||
+46
-5
@@ -15,6 +15,8 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.futo.platformplayer.*
|
import com.futo.platformplayer.*
|
||||||
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
import com.futo.platformplayer.api.media.structures.IAsyncPager
|
import com.futo.platformplayer.api.media.structures.IAsyncPager
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.constructs.TaskHandler
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
@@ -22,10 +24,14 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFrag
|
|||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.HistoryVideo
|
import com.futo.platformplayer.models.HistoryVideo
|
||||||
import com.futo.platformplayer.states.StateHistory
|
import com.futo.platformplayer.states.StateHistory
|
||||||
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
|
import com.futo.platformplayer.states.StatePlugins
|
||||||
|
import com.futo.platformplayer.views.ToggleBar
|
||||||
import com.futo.platformplayer.views.adapters.HistoryListViewHolder
|
import com.futo.platformplayer.views.adapters.HistoryListViewHolder
|
||||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||||
import com.futo.platformplayer.views.others.TagsView
|
import com.futo.platformplayer.views.others.TagsView
|
||||||
|
import com.futo.platformplayer.views.others.Toggle
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -68,6 +74,8 @@ class HistoryFragment : MainFragment() {
|
|||||||
private var _pager: IPager<HistoryVideo>? = null;
|
private var _pager: IPager<HistoryVideo>? = null;
|
||||||
private val _results = arrayListOf<HistoryVideo>();
|
private val _results = arrayListOf<HistoryVideo>();
|
||||||
private var _loading = false;
|
private var _loading = false;
|
||||||
|
private val _toggleBar: ToggleBar
|
||||||
|
private var _togglePluginsDisabled = hashSetOf<String>()
|
||||||
|
|
||||||
private var _automaticNextPageCounter = 0;
|
private var _automaticNextPageCounter = 0;
|
||||||
|
|
||||||
@@ -79,6 +87,7 @@ class HistoryFragment : MainFragment() {
|
|||||||
_clearSearch = findViewById(R.id.button_clear_search);
|
_clearSearch = findViewById(R.id.button_clear_search);
|
||||||
_editSearch = findViewById(R.id.edit_search);
|
_editSearch = findViewById(R.id.edit_search);
|
||||||
_tagsView = findViewById(R.id.tags_text);
|
_tagsView = findViewById(R.id.tags_text);
|
||||||
|
_toggleBar = findViewById(R.id.toggle_bar)
|
||||||
_tagsView.setPairs(listOf(
|
_tagsView.setPairs(listOf(
|
||||||
Pair(context.getString(R.string.last_hour), 60L),
|
Pair(context.getString(R.string.last_hour), 60L),
|
||||||
Pair(context.getString(R.string.last_24_hours), 24L * 60L),
|
Pair(context.getString(R.string.last_24_hours), 24L * 60L),
|
||||||
@@ -88,6 +97,22 @@ class HistoryFragment : MainFragment() {
|
|||||||
Pair(context.getString(R.string.all_time), -1L)
|
Pair(context.getString(R.string.all_time), -1L)
|
||||||
));
|
));
|
||||||
|
|
||||||
|
val toggles = StatePlatform.instance.getEnabledClients()
|
||||||
|
.filter { it is JSClient }
|
||||||
|
.map { plugin ->
|
||||||
|
val pluginName = plugin.name.lowercase()
|
||||||
|
ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) pluginName else "", plugin.icon, !_togglePluginsDisabled.contains(plugin.id), { view, active ->
|
||||||
|
if (active) {
|
||||||
|
_togglePluginsDisabled.remove(plugin.id)
|
||||||
|
} else {
|
||||||
|
_togglePluginsDisabled.add(plugin.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
filtersChanged()
|
||||||
|
}).withTag("plugins")
|
||||||
|
}.toTypedArray()
|
||||||
|
_toggleBar.setToggles(*toggles)
|
||||||
|
|
||||||
_adapter = InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
|
_adapter = InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
|
||||||
{ _results.size },
|
{ _results.size },
|
||||||
{ view, _ ->
|
{ view, _ ->
|
||||||
@@ -162,14 +187,15 @@ class HistoryFragment : MainFragment() {
|
|||||||
else
|
else
|
||||||
it.nextPage();
|
it.nextPage();
|
||||||
|
|
||||||
return@TaskHandler it.getResults();
|
return@TaskHandler filterResults(it.getResults());
|
||||||
}).success {
|
}).success {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
val posBefore = _results.size;
|
val posBefore = _results.size;
|
||||||
_results.addAll(it);
|
val res = filterResults(it)
|
||||||
_adapter.notifyItemRangeInserted(_adapter.childToParentPosition(posBefore), it.size);
|
_results.addAll(res);
|
||||||
ensureEnoughContentVisible(it)
|
_adapter.notifyItemRangeInserted(_adapter.childToParentPosition(posBefore), res.size);
|
||||||
|
ensureEnoughContentVisible(res)
|
||||||
}.exception<Throwable> {
|
}.exception<Throwable> {
|
||||||
Logger.w(TAG, "Failed to load next page.", it);
|
Logger.w(TAG, "Failed to load next page.", it);
|
||||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
|
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
|
||||||
@@ -178,6 +204,10 @@ class HistoryFragment : MainFragment() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun filtersChanged() {
|
||||||
|
updatePager()
|
||||||
|
}
|
||||||
|
|
||||||
private fun updatePager() {
|
private fun updatePager() {
|
||||||
val query = _editSearch.text.toString();
|
val query = _editSearch.text.toString();
|
||||||
if (_editSearch.text.isNotEmpty()) {
|
if (_editSearch.text.isNotEmpty()) {
|
||||||
@@ -246,11 +276,22 @@ class HistoryFragment : MainFragment() {
|
|||||||
_adapter.setLoading(loading);
|
_adapter.setLoading(loading);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun filterResults(a: List<HistoryVideo>): List<HistoryVideo> {
|
||||||
|
val enabledPluginIds = StatePlatform.instance.getEnabledClients().map { it.id }.toHashSet()
|
||||||
|
val disabledPluginIds = _togglePluginsDisabled.toHashSet()
|
||||||
|
return a.filter {
|
||||||
|
val pluginId = it.video.id.pluginId ?: StatePlatform.instance.getContentClientOrNull(it.video.url)?.id ?: return@filter false
|
||||||
|
if (!enabledPluginIds.contains(pluginId))
|
||||||
|
return@filter false
|
||||||
|
return@filter !disabledPluginIds.contains(pluginId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadPagerInternal(pager: IPager<HistoryVideo>) {
|
private fun loadPagerInternal(pager: IPager<HistoryVideo>) {
|
||||||
Logger.i(TAG, "Setting new internal pager on feed");
|
Logger.i(TAG, "Setting new internal pager on feed");
|
||||||
|
|
||||||
_results.clear();
|
_results.clear();
|
||||||
val toAdd = pager.getResults();
|
val toAdd = filterResults(pager.getResults())
|
||||||
_results.addAll(toAdd);
|
_results.addAll(toAdd);
|
||||||
_adapter.notifyDataSetChanged();
|
_adapter.notifyDataSetChanged();
|
||||||
ensureEnoughContentVisible(toAdd)
|
ensureEnoughContentVisible(toAdd)
|
||||||
|
|||||||
+1
-1
@@ -217,7 +217,7 @@ class PlaylistsFragment : MainFragment() {
|
|||||||
var playlistsToReturn = pls;
|
var playlistsToReturn = pls;
|
||||||
if(!_listPlaylistsSearch.text.isNullOrEmpty())
|
if(!_listPlaylistsSearch.text.isNullOrEmpty())
|
||||||
playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) };
|
playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) };
|
||||||
if(!_ordering.value.isNullOrEmpty()){
|
if(!_ordering.value.isNullOrEmpty()) {
|
||||||
playlistsToReturn = when(_ordering.value){
|
playlistsToReturn = when(_ordering.value){
|
||||||
"nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() }
|
"nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() }
|
||||||
"nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() };
|
"nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() };
|
||||||
|
|||||||
+10
-1
@@ -168,7 +168,12 @@ class PostDetailFragment : MainFragment {
|
|||||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
|
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
|
||||||
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
|
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
|
||||||
|
|
||||||
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, 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) }
|
.success { it -> setPolycentricProfile(it, animate = true) }
|
||||||
.exception<Throwable> {
|
.exception<Throwable> {
|
||||||
Logger.w(TAG, "Failed to load claims.", it);
|
Logger.w(TAG, "Failed to load claims.", it);
|
||||||
@@ -327,6 +332,10 @@ class PostDetailFragment : MainFragment {
|
|||||||
val version = _version;
|
val version = _version;
|
||||||
|
|
||||||
_rating.onLikeDislikeUpdated.remove(this);
|
_rating.onLikeDislikeUpdated.remove(this);
|
||||||
|
|
||||||
|
if (!StatePolycentric.instance.enabled)
|
||||||
|
return
|
||||||
|
|
||||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
if (version != _version) {
|
if (version != _version) {
|
||||||
return@launch;
|
return@launch;
|
||||||
|
|||||||
+3
@@ -18,6 +18,7 @@ import com.futo.platformplayer.activities.AddSourceOptionsActivity
|
|||||||
import com.futo.platformplayer.api.media.IPlatformClient
|
import com.futo.platformplayer.api.media.IPlatformClient
|
||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.states.StatePlugins
|
import com.futo.platformplayer.states.StatePlugins
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
@@ -44,6 +45,7 @@ class SourcesFragment : MainFragment() {
|
|||||||
if(topBar is AddTopBarFragment) {
|
if(topBar is AddTopBarFragment) {
|
||||||
(topBar as AddTopBarFragment).onAdd.clear();
|
(topBar as AddTopBarFragment).onAdd.clear();
|
||||||
(topBar as AddTopBarFragment).onAdd.subscribe {
|
(topBar as AddTopBarFragment).onAdd.subscribe {
|
||||||
|
StateApp.instance.preventPictureInPicture.emit();
|
||||||
startActivity(Intent(requireContext(), AddSourceOptionsActivity::class.java));
|
startActivity(Intent(requireContext(), AddSourceOptionsActivity::class.java));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -93,6 +95,7 @@ class SourcesFragment : MainFragment() {
|
|||||||
findViewById<LinearLayout>(R.id.plugin_disclaimer).isVisible = false;
|
findViewById<LinearLayout>(R.id.plugin_disclaimer).isVisible = false;
|
||||||
}
|
}
|
||||||
findViewById<BigButton>(R.id.button_add_sources).onClick.subscribe {
|
findViewById<BigButton>(R.id.button_add_sources).onClick.subscribe {
|
||||||
|
StateApp.instance.preventPictureInPicture.emit();
|
||||||
fragment.startActivity(Intent(context, AddSourceOptionsActivity::class.java));
|
fragment.startActivity(Intent(context, AddSourceOptionsActivity::class.java));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -290,8 +290,8 @@ class SubscriptionGroupFragment : MainFragment() {
|
|||||||
image.setImageView(_imageGroup);
|
image.setImageView(_imageGroup);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
_imageGroupBackground.setImageResource(0);
|
_imageGroupBackground.setImageDrawable(null);
|
||||||
_imageGroup.setImageResource(0);
|
_imageGroup.setImageDrawable(null);
|
||||||
}
|
}
|
||||||
updateMeta();
|
updateMeta();
|
||||||
reloadCreators(group);
|
reloadCreators(group);
|
||||||
|
|||||||
+3
-6
@@ -21,7 +21,7 @@ import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter
|
|||||||
import com.futo.platformplayer.views.others.RadioGroupView
|
import com.futo.platformplayer.views.others.RadioGroupView
|
||||||
import com.futo.platformplayer.views.others.TagsView
|
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 {
|
class SuggestionsFragment : MainFragment {
|
||||||
override val isMainView : Boolean = true;
|
override val isMainView : Boolean = true;
|
||||||
@@ -34,7 +34,6 @@ class SuggestionsFragment : MainFragment {
|
|||||||
private val _suggestions: ArrayList<String> = ArrayList();
|
private val _suggestions: ArrayList<String> = ArrayList();
|
||||||
private var _query: String? = null;
|
private var _query: String? = null;
|
||||||
private var _searchType: SearchType = SearchType.VIDEO;
|
private var _searchType: SearchType = SearchType.VIDEO;
|
||||||
private var _channelUrl: String? = null;
|
|
||||||
|
|
||||||
private val _adapterSuggestions = SearchSuggestionAdapter(_suggestions);
|
private val _adapterSuggestions = SearchSuggestionAdapter(_suggestions);
|
||||||
|
|
||||||
@@ -52,7 +51,7 @@ class SuggestionsFragment : MainFragment {
|
|||||||
_adapterSuggestions.onClicked.subscribe { suggestion ->
|
_adapterSuggestions.onClicked.subscribe { suggestion ->
|
||||||
val storage = FragmentedStorage.get<SearchHistoryStorage>();
|
val storage = FragmentedStorage.get<SearchHistoryStorage>();
|
||||||
storage.add(suggestion);
|
storage.add(suggestion);
|
||||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, _searchType, _channelUrl));
|
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, _searchType));
|
||||||
}
|
}
|
||||||
_adapterSuggestions.onRemove.subscribe { suggestion ->
|
_adapterSuggestions.onRemove.subscribe { suggestion ->
|
||||||
val index = _suggestions.indexOf(suggestion);
|
val index = _suggestions.indexOf(suggestion);
|
||||||
@@ -109,10 +108,8 @@ class SuggestionsFragment : MainFragment {
|
|||||||
|
|
||||||
if (parameter is SuggestionsFragmentData) {
|
if (parameter is SuggestionsFragmentData) {
|
||||||
_searchType = parameter.searchType;
|
_searchType = parameter.searchType;
|
||||||
_channelUrl = parameter.channelUrl;
|
|
||||||
} else if (parameter is SearchType) {
|
} else if (parameter is SearchType) {
|
||||||
_searchType = parameter;
|
_searchType = parameter;
|
||||||
_channelUrl = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_radioGroupView?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true)
|
_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
|
else
|
||||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, _searchType, _channelUrl));
|
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, _searchType));
|
||||||
};
|
};
|
||||||
|
|
||||||
onTextChange.subscribe(this) {
|
onTextChange.subscribe(this) {
|
||||||
|
|||||||
+8
@@ -455,6 +455,10 @@ class VideoDetailFragment() : MainFragment() {
|
|||||||
activity?.enterPictureInPictureMode(params);
|
activity?.enterPictureInPictureMode(params);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isFullscreen) {
|
||||||
|
viewDetail?.restoreBrightness()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun forcePictureInPicture() {
|
fun forcePictureInPicture() {
|
||||||
@@ -487,6 +491,10 @@ class VideoDetailFragment() : MainFragment() {
|
|||||||
_isActive = true;
|
_isActive = true;
|
||||||
_leavingPiP = false;
|
_leavingPiP = false;
|
||||||
|
|
||||||
|
if (isFullscreen) {
|
||||||
|
_viewDetail?.saveBrightness()
|
||||||
|
}
|
||||||
|
|
||||||
_viewDetail?.let {
|
_viewDetail?.let {
|
||||||
Logger.v(TAG, "onResume preventPictureInPicture=false");
|
Logger.v(TAG, "onResume preventPictureInPicture=false");
|
||||||
it.preventPictureInPicture = false;
|
it.preventPictureInPicture = false;
|
||||||
|
|||||||
+104
-59
@@ -46,6 +46,7 @@ import com.futo.platformplayer.R
|
|||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.UISlideOverlays
|
import com.futo.platformplayer.UISlideOverlays
|
||||||
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.api.media.IPluginSourced
|
import com.futo.platformplayer.api.media.IPluginSourced
|
||||||
import com.futo.platformplayer.api.media.LiveChatManager
|
import com.futo.platformplayer.api.media.LiveChatManager
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
@@ -148,7 +149,6 @@ import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
|
|||||||
import com.futo.platformplayer.views.pills.RoundButton
|
import com.futo.platformplayer.views.pills.RoundButton
|
||||||
import com.futo.platformplayer.views.pills.RoundButtonGroup
|
import com.futo.platformplayer.views.pills.RoundButtonGroup
|
||||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
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.segments.CommentsList
|
||||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||||
import com.futo.platformplayer.views.video.FutoVideoPlayer
|
import com.futo.platformplayer.views.video.FutoVideoPlayer
|
||||||
@@ -571,7 +571,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_player.setIsReplay(true);
|
_player.setIsReplay(true);
|
||||||
|
|
||||||
val searchVideo = StatePlayer.instance.getCurrentQueueItem();
|
val searchVideo = StatePlayer.instance.getCurrentQueueItem();
|
||||||
if (searchVideo is SerializedPlatformVideo?) {
|
if (searchVideo is SerializedPlatformVideo? && Settings.instance.playback.deleteFromWatchLaterAuto) {
|
||||||
searchVideo?.let { StatePlaylists.instance.removeFromWatchLater(it) };
|
searchVideo?.let { StatePlaylists.instance.removeFromWatchLater(it) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,6 +647,15 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_timeBar.setDuration(video?.duration ?: 0);
|
_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;
|
_playerProgress.player = _player.exoPlayer?.player;
|
||||||
@@ -688,6 +697,20 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
Logger.i(TAG, "MediaControlReceiver.onCloseReceived")
|
Logger.i(TAG, "MediaControlReceiver.onCloseReceived")
|
||||||
onClose.emit()
|
onClose.emit()
|
||||||
};
|
};
|
||||||
|
MediaControlReceiver.onBackgroundReceived.subscribe(this) {
|
||||||
|
Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived")
|
||||||
|
_player.switchToAudioMode();
|
||||||
|
allowBackground = true;
|
||||||
|
StateApp.instance.contextOrNull?.let {
|
||||||
|
try {
|
||||||
|
if (it is MainActivity) {
|
||||||
|
it.moveTaskToBack(true)
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.i(TAG, "Failed to move task to back", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
MediaControlReceiver.onSeekToReceived.subscribe(this) { handleSeek(it); };
|
MediaControlReceiver.onSeekToReceived.subscribe(this) { handleSeek(it); };
|
||||||
|
|
||||||
_container_content_description.onClose.subscribe { switchContentView(_container_content_main); };
|
_container_content_description.onClose.subscribe { switchContentView(_container_content_main); };
|
||||||
@@ -767,6 +790,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
_lastAudioSource = null;
|
_lastAudioSource = null;
|
||||||
_lastSubtitleSource = null;
|
_lastSubtitleSource = null;
|
||||||
video = null;
|
video = null;
|
||||||
|
_container_content_liveChat?.close();
|
||||||
_player.clear();
|
_player.clear();
|
||||||
cleanupPlaybackTracker();
|
cleanupPlaybackTracker();
|
||||||
Logger.i(TAG, "Keep screen on unset onClose")
|
Logger.i(TAG, "Keep screen on unset onClose")
|
||||||
@@ -1141,6 +1165,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
MediaControlReceiver.onNextReceived.remove(this);
|
MediaControlReceiver.onNextReceived.remove(this);
|
||||||
MediaControlReceiver.onPreviousReceived.remove(this);
|
MediaControlReceiver.onPreviousReceived.remove(this);
|
||||||
MediaControlReceiver.onCloseReceived.remove(this);
|
MediaControlReceiver.onCloseReceived.remove(this);
|
||||||
|
MediaControlReceiver.onBackgroundReceived.remove(this);
|
||||||
MediaControlReceiver.onSeekToReceived.remove(this);
|
MediaControlReceiver.onSeekToReceived.remove(this);
|
||||||
|
|
||||||
val job = _jobHideResume;
|
val job = _jobHideResume;
|
||||||
@@ -1510,60 +1535,68 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
_rating.visibility = View.GONE;
|
_rating.visibility = View.GONE;
|
||||||
|
|
||||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
if (StatePolycentric.instance.enabled) {
|
||||||
try {
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val queryReferencesResponse = ApiMethods.getQueryReferences(
|
try {
|
||||||
ApiMethods.SERVER, ref, null, null,
|
val queryReferencesResponse = ApiMethods.getQueryReferences(
|
||||||
arrayListOf(
|
ApiMethods.SERVER, ref, null, null,
|
||||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
arrayListOf(
|
||||||
.setFromType(ContentType.OPINION.value).setValue(
|
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||||
ByteString.copyFrom(Opinion.like.data)
|
.setFromType(ContentType.OPINION.value).setValue(
|
||||||
).build(),
|
ByteString.copyFrom(Opinion.like.data)
|
||||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
).build(),
|
||||||
.setFromType(ContentType.OPINION.value).setValue(
|
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||||
ByteString.copyFrom(Opinion.dislike.data)
|
.setFromType(ContentType.OPINION.value).setValue(
|
||||||
).build()
|
ByteString.copyFrom(Opinion.dislike.data)
|
||||||
),
|
).build()
|
||||||
extraByteReferences = listOfNotNull(extraBytesRef)
|
),
|
||||||
);
|
extraByteReferences = listOfNotNull(extraBytesRef)
|
||||||
|
);
|
||||||
|
|
||||||
val likes = queryReferencesResponse.countsList[0];
|
val likes = queryReferencesResponse.countsList[0];
|
||||||
val dislikes = queryReferencesResponse.countsList[1];
|
val dislikes = queryReferencesResponse.countsList[1];
|
||||||
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
|
val hasLiked =
|
||||||
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
|
StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
|
||||||
|
val hasDisliked =
|
||||||
|
StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
_rating.visibility = View.VISIBLE;
|
_rating.visibility = View.VISIBLE;
|
||||||
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
|
_rating.setRating(
|
||||||
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
|
RatingLikeDislikes(likes, dislikes),
|
||||||
if (args.hasLiked) {
|
hasLiked,
|
||||||
args.processHandle.opinion(ref, Opinion.like);
|
hasDisliked
|
||||||
} else if (args.hasDisliked) {
|
);
|
||||||
args.processHandle.opinion(ref, Opinion.dislike);
|
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
|
||||||
} else {
|
if (args.hasLiked) {
|
||||||
args.processHandle.opinion(ref, Opinion.neutral);
|
args.processHandle.opinion(ref, Opinion.like);
|
||||||
}
|
} else if (args.hasDisliked) {
|
||||||
|
args.processHandle.opinion(ref, Opinion.dislike);
|
||||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
} else {
|
||||||
try {
|
args.processHandle.opinion(ref, Opinion.neutral);
|
||||||
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(
|
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
ref,
|
try {
|
||||||
args.hasLiked,
|
Logger.i(TAG, "Started backfill");
|
||||||
args.hasDisliked
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2413,6 +2446,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
Logger.i(TAG, "handleFullScreen(fullscreen=$fullscreen)")
|
Logger.i(TAG, "handleFullScreen(fullscreen=$fullscreen)")
|
||||||
|
|
||||||
if(fullscreen) {
|
if(fullscreen) {
|
||||||
|
_container_content.visibility = GONE
|
||||||
_layoutPlayerContainer.setPadding(0, 0, 0, 0);
|
_layoutPlayerContainer.setPadding(0, 0, 0, 0);
|
||||||
|
|
||||||
val lp = _container_content.layoutParams as LayoutParams;
|
val lp = _container_content.layoutParams as LayoutParams;
|
||||||
@@ -2426,6 +2460,7 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
setProgressBarOverlayed(null);
|
setProgressBarOverlayed(null);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
_container_content.visibility = VISIBLE
|
||||||
_layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt());
|
_layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt());
|
||||||
|
|
||||||
val lp = _container_content.layoutParams as LayoutParams;
|
val lp = _container_content.layoutParams as LayoutParams;
|
||||||
@@ -2485,6 +2520,13 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun saveBrightness() {
|
||||||
|
_player.gestureControl.saveBrightness()
|
||||||
|
}
|
||||||
|
fun restoreBrightness() {
|
||||||
|
_player.gestureControl.restoreBrightness()
|
||||||
|
}
|
||||||
|
|
||||||
fun setFullscreen(fullscreen : Boolean) {
|
fun setFullscreen(fullscreen : Boolean) {
|
||||||
Logger.i(TAG, "setFullscreen(fullscreen=$fullscreen)")
|
Logger.i(TAG, "setFullscreen(fullscreen=$fullscreen)")
|
||||||
_player.setFullScreen(fullscreen)
|
_player.setFullScreen(fullscreen)
|
||||||
@@ -2648,9 +2690,10 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onChannelClicked.subscribe {
|
onChannelClicked.subscribe {
|
||||||
if(it.url.isNotBlank())
|
if(it.url.isNotBlank()) {
|
||||||
|
fragment.minimizeVideoDetail()
|
||||||
fragment.navigate<ChannelFragment>(it)
|
fragment.navigate<ChannelFragment>(it)
|
||||||
else
|
} else
|
||||||
UIDialogs.appToast("No author url present");
|
UIDialogs.appToast("No author url present");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2725,10 +2768,11 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
else
|
else
|
||||||
RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), context.getString(R.string.play), context.getString(R.string.resumes_the_video), MediaControlReceiver.getPlayIntent(context, 6));
|
RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), context.getString(R.string.play), context.getString(R.string.resumes_the_video), MediaControlReceiver.getPlayIntent(context, 6));
|
||||||
|
|
||||||
|
val toBackgroundAction = RemoteAction(Icon.createWithResource(context, R.drawable.ic_screen_share), context.getString(R.string.background), context.getString(R.string.background_switch_audio), MediaControlReceiver.getToBackgroundIntent(context, 7));
|
||||||
return PictureInPictureParams.Builder()
|
return PictureInPictureParams.Builder()
|
||||||
.setAspectRatio(Rational(videoSourceWidth, videoSourceHeight))
|
.setAspectRatio(Rational(videoSourceWidth, videoSourceHeight))
|
||||||
.setSourceRectHint(r)
|
.setSourceRectHint(r)
|
||||||
.setActions(listOf(playpauseAction))
|
.setActions(listOf(toBackgroundAction, playpauseAction))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3041,7 +3085,12 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
Logger.w(TAG, "Failed to load recommendations.", it);
|
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) }
|
.success { it -> setPolycentricProfile(it, animate = true) }
|
||||||
.exception<Throwable> {
|
.exception<Throwable> {
|
||||||
Logger.w(TAG, "Failed to load claims.", it);
|
Logger.w(TAG, "Failed to load claims.", it);
|
||||||
@@ -3113,10 +3162,6 @@ class VideoDetailView : ConstraintLayout {
|
|||||||
|
|
||||||
fun applyFragment(frag: VideoDetailFragment) {
|
fun applyFragment(frag: VideoDetailFragment) {
|
||||||
fragment = frag;
|
fragment = frag;
|
||||||
fragment.onMinimize.subscribe {
|
|
||||||
_liveChat?.stop();
|
|
||||||
_container_content_liveChat.close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -224,7 +224,8 @@ abstract class VideoListEditorView : LinearLayout {
|
|||||||
|
|
||||||
fun updateVideoFilters() {
|
fun updateVideoFilters() {
|
||||||
val videos = _loadedVideos ?: return;
|
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) {
|
protected fun setButtonDownloadVisible(isVisible: Boolean) {
|
||||||
|
|||||||
+223
@@ -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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-2
@@ -88,7 +88,6 @@ class SearchTopBarFragment : TopFragment() {
|
|||||||
} else if (parameter is SuggestionsFragmentData) {
|
} else if (parameter is SuggestionsFragmentData) {
|
||||||
this.setText(parameter.query);
|
this.setText(parameter.query);
|
||||||
_searchType = parameter.searchType;
|
_searchType = parameter.searchType;
|
||||||
_channelUrl = parameter.channelUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if(currentMain is SuggestionsFragment)
|
if(currentMain is SuggestionsFragment)
|
||||||
@@ -114,7 +113,7 @@ class SearchTopBarFragment : TopFragment() {
|
|||||||
fun clear() {
|
fun clear() {
|
||||||
_editSearch?.text?.clear();
|
_editSearch?.text?.clear();
|
||||||
if (currentMain !is SuggestionsFragment) {
|
if (currentMain !is SuggestionsFragment) {
|
||||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", _searchType, _channelUrl), false);
|
navigate<SuggestionsFragment>(SuggestionsFragmentData("", _searchType), false);
|
||||||
} else {
|
} else {
|
||||||
onSearch.emit("");
|
onSearch.emit("");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ data class ImageVariable(
|
|||||||
Glide.with(imageView)
|
Glide.with(imageView)
|
||||||
.load(bitmap)
|
.load(bitmap)
|
||||||
.into(imageView)
|
.into(imageView)
|
||||||
} else if(resId != null) {
|
} else if(resId != null && resId > 0) {
|
||||||
Glide.with(imageView)
|
Glide.with(imageView)
|
||||||
.load(resId)
|
.load(resId)
|
||||||
.into(imageView)
|
.into(imageView)
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ class LoginWebViewClient : WebViewClient {
|
|||||||
//val domainParts = domain!!.split(".");
|
//val domainParts = domain!!.split(".");
|
||||||
//val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
//val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||||
val cookieDomain = domain!!.getSubdomainWildcardQuery();
|
val cookieDomain = domain!!.getSubdomainWildcardQuery();
|
||||||
if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) })
|
if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || domain.matchesDomain(it) })
|
||||||
_authConfig.cookiesToFind?.let { cookiesToFind ->
|
_authConfig.cookiesToFind?.let { cookiesToFind ->
|
||||||
val cookies = cookieString.split(";");
|
val cookies = cookieString.split(";");
|
||||||
for(cookieStr in cookies) {
|
for(cookieStr in cookies) {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class WebViewRequirementExtractor {
|
|||||||
if(cookieString != null) {
|
if(cookieString != null) {
|
||||||
//val domainParts = domain!!.split(".");
|
//val domainParts = domain!!.split(".");
|
||||||
val cookieDomain = domain!!.getSubdomainWildcardQuery()//"." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
val cookieDomain = domain!!.getSubdomainWildcardQuery()//"." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||||
if(allowedUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) })
|
if(allowedUrls.any { it == "everywhere" || domain.matchesDomain(it) })
|
||||||
cookiesToFind?.let { cookiesToFind ->
|
cookiesToFind?.let { cookiesToFind ->
|
||||||
val cookies = cookieString.split(";");
|
val cookies = cookieString.split(";");
|
||||||
for(cookieStr in cookies) {
|
for(cookieStr in cookies) {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
package com.futo.platformplayer.parsers
|
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.HLSVariantAudioUrlSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource
|
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
|
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.api.media.models.streams.sources.IHLSManifestSource
|
||||||
import com.futo.platformplayer.toYesNo
|
import com.futo.platformplayer.toYesNo
|
||||||
import com.futo.platformplayer.yesNoToBoolean
|
import com.futo.platformplayer.yesNoToBoolean
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlin.text.ifEmpty
|
||||||
|
|
||||||
class HLS {
|
class HLS {
|
||||||
companion object {
|
companion object {
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist {
|
fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist {
|
||||||
val baseUrl = URI(sourceUrl).resolve("./").toString()
|
val baseUrl = URI(sourceUrl).resolve("./").toString()
|
||||||
|
|
||||||
@@ -49,6 +58,31 @@ class HLS {
|
|||||||
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments)
|
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 {
|
fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist {
|
||||||
val lines = content.lines()
|
val lines = content.lines()
|
||||||
val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull()
|
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 playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":")
|
||||||
val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) }
|
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>()
|
val segments = mutableListOf<Segment>()
|
||||||
|
if (initSegment != null) {
|
||||||
|
segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment)))
|
||||||
|
}
|
||||||
|
|
||||||
var currentSegment: MediaSegment? = null
|
var currentSegment: MediaSegment? = null
|
||||||
lines.forEach { line ->
|
lines.forEach { line ->
|
||||||
when {
|
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> {
|
fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> {
|
||||||
@@ -270,7 +322,7 @@ class HLS {
|
|||||||
val name: String?,
|
val name: String?,
|
||||||
val isDefault: Boolean?,
|
val isDefault: Boolean?,
|
||||||
val isAutoSelect: Boolean?,
|
val isAutoSelect: Boolean?,
|
||||||
val isForced: Boolean?
|
val isForced: Boolean?,
|
||||||
) {
|
) {
|
||||||
fun toM3U8Line(): String = buildString {
|
fun toM3U8Line(): String = buildString {
|
||||||
append("#EXT-X-MEDIA:")
|
append("#EXT-X-MEDIA:")
|
||||||
@@ -319,30 +371,13 @@ class HLS {
|
|||||||
|
|
||||||
fun getVideoSources(): List<HLSVariantVideoUrlSource> {
|
fun getVideoSources(): List<HLSVariantVideoUrlSource> {
|
||||||
return variantPlaylistsRefs.map {
|
return variantPlaylistsRefs.map {
|
||||||
var width: Int? = null
|
variantReferenceToVariant(it)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAudioSources(): List<HLSVariantAudioUrlSource> {
|
fun getAudioSources(): List<HLSVariantAudioUrlSource> {
|
||||||
return mediaRenditions.mapNotNull {
|
return mediaRenditions.mapNotNull {
|
||||||
if (it.uri == null) {
|
return@mapNotNull mediaRenditionToVariant(it)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,6 +403,11 @@ class HLS {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class DecryptionInfo(
|
||||||
|
val keyUrl: String,
|
||||||
|
val iv: String?
|
||||||
|
)
|
||||||
|
|
||||||
data class VariantPlaylist(
|
data class VariantPlaylist(
|
||||||
val version: Int?,
|
val version: Int?,
|
||||||
val targetDuration: Int?,
|
val targetDuration: Int?,
|
||||||
@@ -376,7 +416,8 @@ class HLS {
|
|||||||
val programDateTime: ZonedDateTime?,
|
val programDateTime: ZonedDateTime?,
|
||||||
val playlistType: String?,
|
val playlistType: String?,
|
||||||
val streamInfo: StreamInfo?,
|
val streamInfo: StreamInfo?,
|
||||||
val segments: List<Segment>
|
val segments: List<Segment>,
|
||||||
|
val decryptionInfo: DecryptionInfo? = null
|
||||||
) {
|
) {
|
||||||
fun buildM3U8(): String = buildString {
|
fun buildM3U8(): String = buildString {
|
||||||
append("#EXTM3U\n")
|
append("#EXTM3U\n")
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class MediaControlReceiver : BroadcastReceiver() {
|
|||||||
EVENT_NEXT -> onNextReceived.emit();
|
EVENT_NEXT -> onNextReceived.emit();
|
||||||
EVENT_PREV -> onPreviousReceived.emit();
|
EVENT_PREV -> onPreviousReceived.emit();
|
||||||
EVENT_CLOSE -> onCloseReceived.emit();
|
EVENT_CLOSE -> onCloseReceived.emit();
|
||||||
|
EVENT_BACKGROUND -> onBackgroundReceived.emit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
@@ -38,6 +39,7 @@ class MediaControlReceiver : BroadcastReceiver() {
|
|||||||
const val EVENT_NEXT = "Next";
|
const val EVENT_NEXT = "Next";
|
||||||
const val EVENT_PREV = "Prev";
|
const val EVENT_PREV = "Prev";
|
||||||
const val EVENT_CLOSE = "Close";
|
const val EVENT_CLOSE = "Close";
|
||||||
|
const val EVENT_BACKGROUND = "Background";
|
||||||
|
|
||||||
val onPlayReceived = Event0();
|
val onPlayReceived = Event0();
|
||||||
val onPauseReceived = Event0();
|
val onPauseReceived = Event0();
|
||||||
@@ -48,6 +50,7 @@ class MediaControlReceiver : BroadcastReceiver() {
|
|||||||
val onLowerVolumeReceived = Event0();
|
val onLowerVolumeReceived = Event0();
|
||||||
|
|
||||||
val onCloseReceived = Event0()
|
val onCloseReceived = Event0()
|
||||||
|
val onBackgroundReceived = Event0()
|
||||||
|
|
||||||
fun getPlayIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply {
|
fun getPlayIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply {
|
||||||
this.putExtra(EXTRA_MEDIA_ACTION, EVENT_PLAY);
|
this.putExtra(EXTRA_MEDIA_ACTION, EVENT_PLAY);
|
||||||
@@ -64,5 +67,8 @@ class MediaControlReceiver : BroadcastReceiver() {
|
|||||||
fun getCloseIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply {
|
fun getCloseIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply {
|
||||||
this.putExtra(EXTRA_MEDIA_ACTION, EVENT_CLOSE);
|
this.putExtra(EXTRA_MEDIA_ACTION, EVENT_CLOSE);
|
||||||
},PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT);
|
},PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT);
|
||||||
|
fun getToBackgroundIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply {
|
||||||
|
this.putExtra(EXTRA_MEDIA_ACTION, EVENT_BACKGROUND);
|
||||||
|
},PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.futo.platformplayer.serializers
|
package com.futo.platformplayer.serializers
|
||||||
|
|
||||||
|
import com.futo.platformplayer.sToOffsetDateTimeUTC
|
||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.serialization.KSerializer
|
||||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
@@ -37,7 +38,7 @@ class OffsetDateTimeSerializer : KSerializer<OffsetDateTime> {
|
|||||||
return OffsetDateTime.MAX;
|
return OffsetDateTime.MAX;
|
||||||
else if(epochSecond < -9999999999)
|
else if(epochSecond < -9999999999)
|
||||||
return OffsetDateTime.MIN;
|
return OffsetDateTime.MIN;
|
||||||
return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
return epochSecond.sToOffsetDateTimeUTC()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class OffsetDateTimeStringSerializer : KSerializer<OffsetDateTime> {
|
class OffsetDateTimeStringSerializer : KSerializer<OffsetDateTime> {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import com.futo.platformplayer.activities.CaptchaActivity
|
|||||||
import com.futo.platformplayer.activities.IWithResultLauncher
|
import com.futo.platformplayer.activities.IWithResultLauncher
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.activities.SettingsActivity
|
import com.futo.platformplayer.activities.SettingsActivity
|
||||||
|
import com.futo.platformplayer.activities.SettingsActivity.Companion.settingsActivityClosed
|
||||||
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
import com.futo.platformplayer.background.BackgroundWorker
|
import com.futo.platformplayer.background.BackgroundWorker
|
||||||
@@ -414,6 +415,14 @@ class StateApp {
|
|||||||
StateSync.instance.start(context)
|
StateSync.instance.start(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
settingsActivityClosed.subscribe {
|
||||||
|
if (Settings.instance.synchronization.enabled) {
|
||||||
|
StateSync.instance.start(context)
|
||||||
|
} else {
|
||||||
|
StateSync.instance.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Logger.onLogSubmitted.subscribe {
|
Logger.onLogSubmitted.subscribe {
|
||||||
scopeOrNull?.launch(Dispatchers.Main) {
|
scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
try {
|
try {
|
||||||
@@ -509,10 +518,17 @@ class StateApp {
|
|||||||
|
|
||||||
//Migration
|
//Migration
|
||||||
Logger.i(TAG, "MainApp Started: Check [Migrations]");
|
Logger.i(TAG, "MainApp Started: Check [Migrations]");
|
||||||
migrateStores(context, listOf(
|
|
||||||
StateSubscriptions.instance.toMigrateCheck(),
|
scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
StatePlaylists.instance.toMigrateCheck()
|
try {
|
||||||
).flatten(), 0);
|
migrateStores(context, listOf(
|
||||||
|
StateSubscriptions.instance.toMigrateCheck(),
|
||||||
|
StatePlaylists.instance.toMigrateCheck()
|
||||||
|
).flatten(), 0)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to migrate stores")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if(Settings.instance.subscriptions.fetchOnAppBoot) {
|
if(Settings.instance.subscriptions.fetchOnAppBoot) {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
@@ -679,15 +695,27 @@ class StateApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun migrateStores(context: Context, managedStores: List<ManagedStore<*>>, index: Int) {
|
private suspend fun migrateStores(context: Context, managedStores: List<ManagedStore<*>>, index: Int) {
|
||||||
if(managedStores.size <= index)
|
if(managedStores.size <= index)
|
||||||
return;
|
return;
|
||||||
val store = managedStores[index];
|
val store = managedStores[index];
|
||||||
if(store.hasMissingReconstructions())
|
if(store.hasMissingReconstructions()) {
|
||||||
UIDialogs.showMigrateDialog(context, store) {
|
withContext(Dispatchers.Main) {
|
||||||
migrateStores(context, managedStores, index + 1);
|
try {
|
||||||
};
|
UIDialogs.showMigrateDialog(context, store) {
|
||||||
else
|
scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
migrateStores(context, managedStores, index + 1);
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to migrate store", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to migrate stores", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else
|
||||||
migrateStores(context, managedStores, index + 1);
|
migrateStores(context, managedStores, index + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,6 +735,7 @@ class StateApp {
|
|||||||
|
|
||||||
StatePlayer.instance.closeMediaSession();
|
StatePlayer.instance.closeMediaSession();
|
||||||
StateCasting.instance.stop();
|
StateCasting.instance.stop();
|
||||||
|
StateSync.instance.stop();
|
||||||
StatePlayer.dispose();
|
StatePlayer.dispose();
|
||||||
Companion.dispose();
|
Companion.dispose();
|
||||||
_fileLogConsumer?.close();
|
_fileLogConsumer?.close();
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ class StateDownloads {
|
|||||||
}
|
}
|
||||||
private fun validateDownload(videoState: VideoDownload) {
|
private fun validateDownload(videoState: VideoDownload) {
|
||||||
if(_downloading.hasItem { it.videoEither.url == videoState.videoEither.url })
|
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);
|
val existing = getCachedVideo(videoState.id);
|
||||||
if(existing != null) {
|
if(existing != null) {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import androidx.collection.LruCache
|
|||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
|
||||||
import com.futo.platformplayer.api.media.IPlatformClient
|
import com.futo.platformplayer.api.media.IPlatformClient
|
||||||
import com.futo.platformplayer.api.media.IPluginSourced
|
import com.futo.platformplayer.api.media.IPluginSourced
|
||||||
import com.futo.platformplayer.api.media.PlatformMultiClientPool
|
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.models.ImageVariable
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.futo.platformplayer.stores.StringArrayStorage
|
import com.futo.platformplayer.stores.StringArrayStorage
|
||||||
import com.futo.platformplayer.stores.StringStorage
|
|
||||||
import com.futo.platformplayer.views.ToastView
|
import com.futo.platformplayer.views.ToastView
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
@@ -56,7 +54,6 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.internal.concat
|
|
||||||
import java.lang.Thread.sleep
|
import java.lang.Thread.sleep
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import kotlin.streams.asSequence
|
import kotlin.streams.asSequence
|
||||||
@@ -94,9 +91,11 @@ class StatePlatform {
|
|||||||
private val _trackerClientPool = PlatformMultiClientPool("Trackers", 1); //Used exclusively for playback trackers
|
private val _trackerClientPool = PlatformMultiClientPool("Trackers", 1); //Used exclusively for playback trackers
|
||||||
private val _liveEventClientPool = PlatformMultiClientPool("LiveEvents", 1); //Used exclusively for live events
|
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 _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();
|
private val _icons : HashMap<String, ImageVariable> = HashMap();
|
||||||
|
private val _iconsByName : HashMap<String, ImageVariable> = HashMap();
|
||||||
|
|
||||||
val hasClients: Boolean get() = _availableClients.size > 0;
|
val hasClients: Boolean get() = _availableClients.size > 0;
|
||||||
|
|
||||||
@@ -113,14 +112,14 @@ class StatePlatform {
|
|||||||
|
|
||||||
Logger.i(StatePlatform::class.java.name, "Fetching video details [${url}]");
|
Logger.i(StatePlatform::class.java.name, "Fetching video details [${url}]");
|
||||||
if(!StateApp.instance.privateMode) {
|
if(!StateApp.instance.privateMode) {
|
||||||
_enabledClients.find { it.isContentDetailsUrl(url) }?.let {
|
_enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let {
|
||||||
_mainClientPool.getClientPooled(it).getContentDetails(url)
|
_mainClientPool.getClientPooled(it).getContentDetails(url)
|
||||||
}
|
}
|
||||||
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
|
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Logger.i(TAG, "Fetching details with private client");
|
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)
|
_privateClientPool.getClientPooled(it).getContentDetails(url)
|
||||||
}
|
}
|
||||||
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
|
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
|
||||||
@@ -192,6 +191,7 @@ class StatePlatform {
|
|||||||
_availableClients.clear();
|
_availableClients.clear();
|
||||||
|
|
||||||
_icons.clear();
|
_icons.clear();
|
||||||
|
_iconsByName.clear()
|
||||||
_icons[StateDeveloper.DEV_ID] = ImageVariable(null, R.drawable.ic_security_red);
|
_icons[StateDeveloper.DEV_ID] = ImageVariable(null, R.drawable.ic_security_red);
|
||||||
|
|
||||||
StatePlugins.instance.updateEmbeddedPlugins(context);
|
StatePlugins.instance.updateEmbeddedPlugins(context);
|
||||||
@@ -200,6 +200,8 @@ class StatePlatform {
|
|||||||
for (plugin in StatePlugins.instance.getPlugins()) {
|
for (plugin in StatePlugins.instance.getPlugins()) {
|
||||||
_icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?:
|
_icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?:
|
||||||
ImageVariable(plugin.config.absoluteIconUrl, null);
|
ImageVariable(plugin.config.absoluteIconUrl, null);
|
||||||
|
_iconsByName[plugin.config.name.lowercase()] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?:
|
||||||
|
ImageVariable(plugin.config.absoluteIconUrl, null);
|
||||||
|
|
||||||
val client = JSClient(context, plugin);
|
val client = JSClient(context, plugin);
|
||||||
client.onCaptchaException.subscribe { c, ex ->
|
client.onCaptchaException.subscribe { c, ex ->
|
||||||
@@ -299,6 +301,15 @@ class StatePlatform {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getPlatformIconByName(name: String?) : ImageVariable? {
|
||||||
|
if(name == null)
|
||||||
|
return null;
|
||||||
|
val nameLower = name.lowercase()
|
||||||
|
if(_iconsByName.containsKey(nameLower))
|
||||||
|
return _iconsByName[nameLower];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
fun setPlatformOrder(platformOrder: List<String>) {
|
fun setPlatformOrder(platformOrder: List<String>) {
|
||||||
_platformOrderPersistent.values.clear();
|
_platformOrderPersistent.values.clear();
|
||||||
_platformOrderPersistent.values.addAll(platformOrder);
|
_platformOrderPersistent.values.addAll(platformOrder);
|
||||||
@@ -655,10 +666,10 @@ class StatePlatform {
|
|||||||
|
|
||||||
|
|
||||||
//Video
|
//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)
|
fun getContentClient(url: String) : IPlatformClient = getContentClientOrNull(url)
|
||||||
?: throw NoPlatformClientException("No client enabled that supports this content url (${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> {
|
fun getContentDetails(url: String, forceRefetch: Boolean = false): Deferred<IPlatformContentDetails> {
|
||||||
Logger.i(TAG, "Platform - getContentDetails (${url})");
|
Logger.i(TAG, "Platform - getContentDetails (${url})");
|
||||||
if(forceRefetch)
|
if(forceRefetch)
|
||||||
@@ -699,14 +710,14 @@ class StatePlatform {
|
|||||||
return client.getContentRecommendations(url);
|
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)
|
fun getChannelClient(url : String, exclude: List<String>? = null) : IPlatformClient = getChannelClientOrNull(url, exclude)
|
||||||
?: throw NoPlatformClientException("No client enabled that supports this channel url (${url})");
|
?: throw NoPlatformClientException("No client enabled that supports this channel url (${url})");
|
||||||
fun getChannelClientOrNull(url : String, exclude: List<String>? = null) : IPlatformClient? =
|
fun getChannelClientOrNull(url : String, exclude: List<String>? = null) : IPlatformClient? =
|
||||||
if(exclude == null)
|
if(exclude == null)
|
||||||
getEnabledClients().find { it.isChannelUrl(url) }
|
getEnabledClients().find { _instantClientPool.getClientPooled(it).isChannelUrl(url) }
|
||||||
else
|
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> {
|
fun getChannel(url: String, updateSubscriptions: Boolean = true): Deferred<IPlatformChannel> {
|
||||||
Logger.i(TAG, "Platform - getChannel");
|
Logger.i(TAG, "Platform - getChannel");
|
||||||
@@ -718,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 clientCapabilities = baseClient.getChannelCapabilities();
|
||||||
val client = if(usePooledClients > 1)
|
val client = if(usePooledClients > 1)
|
||||||
_channelClientPool.getClientPooled(baseClient, usePooledClients);
|
_channelClientPool.getClientPooled(baseClient, usePooledClients);
|
||||||
@@ -727,66 +738,75 @@ class StatePlatform {
|
|||||||
var lastStream: OffsetDateTime? = null;
|
var lastStream: OffsetDateTime? = null;
|
||||||
|
|
||||||
val pagerResult: IPager<IPlatformContent>;
|
val pagerResult: IPager<IPlatformContent>;
|
||||||
if(!clientCapabilities.hasType(ResultCapabilities.TYPE_MIXED) &&
|
if (type == null) {
|
||||||
( clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS) ||
|
if(!clientCapabilities.hasType(ResultCapabilities.TYPE_MIXED) &&
|
||||||
clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS) ||
|
( clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS) ||
|
||||||
clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE) ||
|
clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS) ||
|
||||||
clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS)
|
clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE) ||
|
||||||
)) {
|
clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS)
|
||||||
val toQuery = mutableListOf<String>();
|
)) {
|
||||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
val toQuery = mutableListOf<String>();
|
||||||
toQuery.add(ResultCapabilities.TYPE_VIDEOS);
|
if(clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
||||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS))
|
toQuery.add(ResultCapabilities.TYPE_VIDEOS);
|
||||||
toQuery.add(ResultCapabilities.TYPE_STREAMS);
|
if(clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS))
|
||||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE))
|
toQuery.add(ResultCapabilities.TYPE_STREAMS);
|
||||||
toQuery.add(ResultCapabilities.TYPE_LIVE);
|
if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE))
|
||||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS))
|
toQuery.add(ResultCapabilities.TYPE_LIVE);
|
||||||
toQuery.add(ResultCapabilities.TYPE_POSTS);
|
if(clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS))
|
||||||
|
toQuery.add(ResultCapabilities.TYPE_POSTS);
|
||||||
|
|
||||||
if(isSubscriptionOptimized) {
|
if(isSubscriptionOptimized) {
|
||||||
val sub = StateSubscriptions.instance.getSubscription(channelUrl);
|
val sub = StateSubscriptions.instance.getSubscription(channelUrl);
|
||||||
if(sub != null) {
|
if(sub != null) {
|
||||||
if(!sub.shouldFetchStreams()) {
|
if(!sub.shouldFetchStreams()) {
|
||||||
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 7 days, skipping live streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
|
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);
|
toQuery.remove(ResultCapabilities.TYPE_LIVE);
|
||||||
}
|
}
|
||||||
if(!sub.shouldFetchLiveStreams()) {
|
if(!sub.shouldFetchLiveStreams()) {
|
||||||
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
|
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
|
||||||
toQuery.remove(ResultCapabilities.TYPE_STREAMS);
|
toQuery.remove(ResultCapabilities.TYPE_STREAMS);
|
||||||
}
|
}
|
||||||
if(!sub.shouldFetchPosts()) {
|
if(!sub.shouldFetchPosts()) {
|
||||||
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 5 days, skipping posts [${sub.lastPost.getNowDiffDays()} days ago]");
|
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 5 days, skipping posts [${sub.lastPost.getNowDiffDays()} days ago]");
|
||||||
toQuery.remove(ResultCapabilities.TYPE_POSTS);
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return@map results;
|
|
||||||
}
|
}
|
||||||
.asSequence()
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
val pager = MultiChronoContentPager(pagers.toTypedArray());
|
//Merged pager
|
||||||
pager.initialize();
|
val pagers = toQuery
|
||||||
pagerResult = pager;
|
.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
|
//Subscription optimization
|
||||||
val sub = StateSubscriptions.instance.getSubscription(channelUrl);
|
val sub = StateSubscriptions.instance.getSubscription(channelUrl);
|
||||||
@@ -838,10 +858,10 @@ class StatePlatform {
|
|||||||
|
|
||||||
return pagerResult;
|
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");
|
Logger.i(TAG, "Platform - getChannelVideos");
|
||||||
val baseClient = getChannelClient(channelUrl, ignorePlugins);
|
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> {
|
fun getChannelContent(channelUrl: String, type: String?, ordering: String = ResultCapabilities.ORDER_CHONOLOGICAL): IPager<IPlatformContent> {
|
||||||
val client = getChannelClient(channelUrl);
|
val client = getChannelClient(channelUrl);
|
||||||
@@ -893,9 +913,9 @@ class StatePlatform {
|
|||||||
return urls;
|
return urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hasEnabledPlaylistClient(url: String) : Boolean = getEnabledClients().any { it.isPlaylistUrl(url) };
|
fun hasEnabledPlaylistClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) };
|
||||||
fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { it.isPlaylistUrl(url) }
|
fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) }
|
||||||
fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { 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})");
|
?: throw NoPlatformClientException("No client enabled that supports this playlist url (${url})");
|
||||||
fun getPlaylist(url: String): IPlatformPlaylistDetails {
|
fun getPlaylist(url: String): IPlatformPlaylistDetails {
|
||||||
return getPlaylistClient(url).getPlaylist(url);
|
return getPlaylistClient(url).getPlaylist(url);
|
||||||
|
|||||||
@@ -598,7 +598,7 @@ class StatePlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(_queuePosition < _queue.size) {
|
if(_queuePosition < _queue.size) {
|
||||||
return _queue[_queuePosition];
|
return getCurrentQueueItem();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import com.futo.platformplayer.exceptions.ReconstructionException
|
|||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.ImportCache
|
import com.futo.platformplayer.models.ImportCache
|
||||||
import com.futo.platformplayer.models.Playlist
|
import com.futo.platformplayer.models.Playlist
|
||||||
|
import com.futo.platformplayer.sToOffsetDateTimeUTC
|
||||||
import com.futo.platformplayer.smartMerge
|
import com.futo.platformplayer.smartMerge
|
||||||
import com.futo.platformplayer.states.StateSubscriptionGroups.Companion
|
import com.futo.platformplayer.states.StateSubscriptionGroups.Companion
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
@@ -85,7 +86,7 @@ class StatePlaylists {
|
|||||||
if(value.isEmpty())
|
if(value.isEmpty())
|
||||||
return OffsetDateTime.MIN;
|
return OffsetDateTime.MIN;
|
||||||
val tryParse = value.toLongOrNull() ?: 0;
|
val tryParse = value.toLongOrNull() ?: 0;
|
||||||
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(tryParse), ZoneOffset.UTC);
|
return tryParse.sToOffsetDateTimeUTC();
|
||||||
}
|
}
|
||||||
private fun setWatchLaterReorderTime() {
|
private fun setWatchLaterReorderTime() {
|
||||||
val now = OffsetDateTime.now(ZoneOffset.UTC);
|
val now = OffsetDateTime.now(ZoneOffset.UTC);
|
||||||
@@ -400,12 +401,15 @@ class StatePlaylists {
|
|||||||
companion object {
|
companion object {
|
||||||
val TAG = "StatePlaylists";
|
val TAG = "StatePlaylists";
|
||||||
private var _instance : StatePlaylists? = null;
|
private var _instance : StatePlaylists? = null;
|
||||||
|
private var _lockObject = Object()
|
||||||
val instance : StatePlaylists
|
val instance : StatePlaylists
|
||||||
get(){
|
get() {
|
||||||
if(_instance == null)
|
synchronized(_lockObject) {
|
||||||
_instance = StatePlaylists();
|
if (_instance == null)
|
||||||
return _instance!!;
|
_instance = StatePlaylists();
|
||||||
};
|
return _instance!!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun finish() {
|
fun finish() {
|
||||||
_instance?.let {
|
_instance?.let {
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ class StatePolycentric {
|
|||||||
return Pair(didUpdate, listOf(url));
|
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()
|
ensureEnabled()
|
||||||
|
|
||||||
//TODO: Currently abusing subscription concurrency for parallelism
|
//TODO: Currently abusing subscription concurrency for parallelism
|
||||||
@@ -248,7 +248,11 @@ class StatePolycentric {
|
|||||||
|
|
||||||
return@mapNotNull Pair(client, scope.async(Dispatchers.IO) {
|
return@mapNotNull Pair(client, scope.async(Dispatchers.IO) {
|
||||||
try {
|
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) {
|
} catch (ex: Throwable) {
|
||||||
Logger.e(TAG, "getChannelContent", ex);
|
Logger.e(TAG, "getChannelContent", ex);
|
||||||
return@async null;
|
return@async null;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
package com.futo.platformplayer.stores
|
package com.futo.platformplayer.stores
|
||||||
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.stores.v2.JsonStoreSerializer
|
import com.futo.platformplayer.stores.v2.JsonStoreSerializer
|
||||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||||
import com.futo.platformplayer.stores.v2.StoreSerializer
|
import com.futo.platformplayer.stores.v2.StoreSerializer
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package com.futo.platformplayer.sync.internal
|
package com.futo.platformplayer.sync.internal
|
||||||
|
|
||||||
|
import com.futo.platformplayer.ensureNotMainThread
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.noise.protocol.CipherStatePair
|
import com.futo.platformplayer.noise.protocol.CipherStatePair
|
||||||
import com.futo.platformplayer.noise.protocol.DHState
|
import com.futo.platformplayer.noise.protocol.DHState
|
||||||
import com.futo.platformplayer.noise.protocol.HandshakeState
|
import com.futo.platformplayer.noise.protocol.HandshakeState
|
||||||
import com.futo.platformplayer.states.StateSync
|
import com.futo.platformplayer.states.StateSync
|
||||||
|
import com.futo.polycentric.core.base64ToByteArray
|
||||||
|
import com.futo.polycentric.core.toBase64
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
@@ -52,6 +55,7 @@ class ChannelSocket(private val session: SyncSocketSession) : IChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, contentEncoding: ContentEncoding?) {
|
override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, contentEncoding: ContentEncoding?) {
|
||||||
|
ensureNotMainThread()
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
session.send(opcode, subOpcode, data, contentEncoding)
|
session.send(opcode, subOpcode, data, contentEncoding)
|
||||||
} else {
|
} else {
|
||||||
@@ -69,12 +73,12 @@ class ChannelRelayed(
|
|||||||
private val sendLock = Object()
|
private val sendLock = Object()
|
||||||
private val decryptLock = Object()
|
private val decryptLock = Object()
|
||||||
private var handshakeState: HandshakeState? = if (initiator) {
|
private var handshakeState: HandshakeState? = if (initiator) {
|
||||||
HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR).apply {
|
HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR).apply {
|
||||||
localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair)
|
localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair)
|
||||||
remotePublicKey.setPublicKey(Base64.getDecoder().decode(publicKey), 0)
|
remotePublicKey.setPublicKey(Base64.getDecoder().decode(publicKey), 0)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
HandshakeState(StateSync.protocolName, HandshakeState.RESPONDER).apply {
|
HandshakeState(SyncService.protocolName, HandshakeState.RESPONDER).apply {
|
||||||
localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair)
|
localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,7 +86,7 @@ class ChannelRelayed(
|
|||||||
override var authorizable: IAuthorizable? = null
|
override var authorizable: IAuthorizable? = null
|
||||||
val isAuthorized: Boolean get() = authorizable?.isAuthorized ?: false
|
val isAuthorized: Boolean get() = authorizable?.isAuthorized ?: false
|
||||||
var connectionId: Long = 0L
|
var connectionId: Long = 0L
|
||||||
override var remotePublicKey: String? = publicKey
|
override var remotePublicKey: String? = publicKey.base64ToByteArray().toBase64()
|
||||||
private set
|
private set
|
||||||
override var remoteVersion: Int? = null
|
override var remoteVersion: Int? = null
|
||||||
private set
|
private set
|
||||||
@@ -92,11 +96,39 @@ class ChannelRelayed(
|
|||||||
private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null
|
private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null
|
||||||
private var onClose: ((IChannel) -> Unit)? = null
|
private var onClose: ((IChannel) -> Unit)? = null
|
||||||
private var disposed = false
|
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 {
|
init {
|
||||||
handshakeState?.start()
|
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)?) {
|
override fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) {
|
||||||
this.onData = onData
|
this.onData = onData
|
||||||
}
|
}
|
||||||
@@ -132,6 +164,10 @@ class ChannelRelayed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun invokeDataHandler(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
|
fun invokeDataHandler(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
|
||||||
|
if (opcode == Opcode.PONG.value) {
|
||||||
|
_lastPongTime = System.currentTimeMillis()
|
||||||
|
return
|
||||||
|
}
|
||||||
onData?.invoke(session, this, opcode, subOpcode, data)
|
onData?.invoke(session, this, opcode, subOpcode, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,10 +182,12 @@ class ChannelRelayed(
|
|||||||
handshakeState = null
|
handshakeState = null
|
||||||
this.transport = transport
|
this.transport = transport
|
||||||
Logger.i("ChannelRelayed", "Completed handshake for connectionId $connectionId")
|
Logger.i("ChannelRelayed", "Completed handshake for connectionId $connectionId")
|
||||||
|
startPingLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sendPacket(packet: ByteArray) {
|
private fun sendPacket(packet: ByteArray) {
|
||||||
throwIfDisposed()
|
throwIfDisposed()
|
||||||
|
ensureNotMainThread()
|
||||||
|
|
||||||
synchronized(sendLock) {
|
synchronized(sendLock) {
|
||||||
val encryptedPayload = ByteArray(packet.size + 16)
|
val encryptedPayload = ByteArray(packet.size + 16)
|
||||||
@@ -167,6 +205,7 @@ class ChannelRelayed(
|
|||||||
|
|
||||||
fun sendError(errorCode: SyncErrorCode) {
|
fun sendError(errorCode: SyncErrorCode) {
|
||||||
throwIfDisposed()
|
throwIfDisposed()
|
||||||
|
ensureNotMainThread()
|
||||||
|
|
||||||
synchronized(sendLock) {
|
synchronized(sendLock) {
|
||||||
val packet = ByteArray(4)
|
val packet = ByteArray(4)
|
||||||
@@ -187,6 +226,7 @@ class ChannelRelayed(
|
|||||||
|
|
||||||
override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, ce: ContentEncoding?) {
|
override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, ce: ContentEncoding?) {
|
||||||
throwIfDisposed()
|
throwIfDisposed()
|
||||||
|
ensureNotMainThread()
|
||||||
|
|
||||||
var contentEncoding: ContentEncoding? = ce
|
var contentEncoding: ContentEncoding? = ce
|
||||||
var processedData = data
|
var processedData = data
|
||||||
@@ -270,6 +310,7 @@ class ChannelRelayed(
|
|||||||
|
|
||||||
fun sendRequestTransport(requestId: Int, publicKey: String, appId: UInt, pairingCode: String? = null) {
|
fun sendRequestTransport(requestId: Int, publicKey: String, appId: UInt, pairingCode: String? = null) {
|
||||||
throwIfDisposed()
|
throwIfDisposed()
|
||||||
|
ensureNotMainThread()
|
||||||
|
|
||||||
synchronized(sendLock) {
|
synchronized(sendLock) {
|
||||||
val channelMessage = ByteArray(1024)
|
val channelMessage = ByteArray(1024)
|
||||||
@@ -310,6 +351,7 @@ class ChannelRelayed(
|
|||||||
|
|
||||||
fun sendResponseTransport(remoteVersion: Int, requestId: Int, handshakeMessage: ByteArray) {
|
fun sendResponseTransport(remoteVersion: Int, requestId: Int, handshakeMessage: ByteArray) {
|
||||||
throwIfDisposed()
|
throwIfDisposed()
|
||||||
|
ensureNotMainThread()
|
||||||
|
|
||||||
synchronized(sendLock) {
|
synchronized(sendLock) {
|
||||||
val message = ByteArray(1024)
|
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
|
package com.futo.platformplayer.sync.internal
|
||||||
|
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.ensureNotMainThread
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.Subscription
|
import com.futo.platformplayer.models.Subscription
|
||||||
import com.futo.platformplayer.states.StateSubscriptions
|
import com.futo.platformplayer.states.StateSubscriptions
|
||||||
@@ -16,6 +17,8 @@ interface IAuthorizable {
|
|||||||
|
|
||||||
class SyncSession : IAuthorizable {
|
class SyncSession : IAuthorizable {
|
||||||
private val _channels: MutableList<IChannel> = mutableListOf()
|
private val _channels: MutableList<IChannel> = mutableListOf()
|
||||||
|
@Volatile
|
||||||
|
private var _snapshot: Array<IChannel> = emptyArray()
|
||||||
private var _authorized: Boolean = false
|
private var _authorized: Boolean = false
|
||||||
private var _remoteAuthorized: Boolean = false
|
private var _remoteAuthorized: Boolean = false
|
||||||
private val _onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit
|
private val _onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit
|
||||||
@@ -82,6 +85,8 @@ class SyncSession : IAuthorizable {
|
|||||||
|
|
||||||
synchronized(_channels) {
|
synchronized(_channels) {
|
||||||
_channels.add(channel)
|
_channels.add(channel)
|
||||||
|
_channels.sortBy { it.linkType.ordinal }
|
||||||
|
_snapshot = _channels.toTypedArray()
|
||||||
connected = _channels.isNotEmpty()
|
connected = _channels.isNotEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,15 +128,20 @@ class SyncSession : IAuthorizable {
|
|||||||
fun removeChannel(channel: IChannel) {
|
fun removeChannel(channel: IChannel) {
|
||||||
synchronized(_channels) {
|
synchronized(_channels) {
|
||||||
_channels.remove(channel)
|
_channels.remove(channel)
|
||||||
|
_snapshot = _channels.toTypedArray()
|
||||||
connected = _channels.isNotEmpty()
|
connected = _channels.isNotEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun close() {
|
fun close() {
|
||||||
synchronized(_channels) {
|
val toClose = synchronized(_channels) {
|
||||||
_channels.toTypedArray()
|
val arr = _channels.toTypedArray()
|
||||||
}.forEach { it.close() }
|
_channels.clear()
|
||||||
|
_snapshot = emptyArray()
|
||||||
|
connected = false
|
||||||
|
arr
|
||||||
|
}
|
||||||
|
toClose.forEach { it.close() }
|
||||||
_onClose(this)
|
_onClose(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,22 +202,25 @@ class SyncSession : IAuthorizable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
inline fun <reified T> sendJsonData(subOpcode: UByte, data: T) {
|
inline fun <reified T> sendJsonData(subOpcode: UByte, data: T) {
|
||||||
|
ensureNotMainThread()
|
||||||
send(Opcode.DATA.value, subOpcode, Json.encodeToString(data))
|
send(Opcode.DATA.value, subOpcode, Json.encodeToString(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendData(subOpcode: UByte, data: String) {
|
fun sendData(subOpcode: UByte, data: String) {
|
||||||
|
ensureNotMainThread()
|
||||||
send(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip)
|
send(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun send(opcode: UByte, subOpcode: UByte, data: String) {
|
fun send(opcode: UByte, subOpcode: UByte, data: String) {
|
||||||
|
ensureNotMainThread()
|
||||||
send(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip)
|
send(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer? = null, contentEncoding: ContentEncoding? = null) {
|
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()) {
|
if (channels.isEmpty()) {
|
||||||
//TODO: Should this throw?
|
Logger.v(TAG, "Packet was not sent … no connected sockets")
|
||||||
Logger.v(TAG, "Packet was not sent (opcode = $opcode, subOpcode = $subOpcode) due to no connected sockets")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
package com.futo.platformplayer.sync.internal
|
package com.futo.platformplayer.sync.internal
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import com.futo.platformplayer.LittleEndianDataInputStream
|
|
||||||
import com.futo.platformplayer.LittleEndianDataOutputStream
|
|
||||||
import com.futo.platformplayer.ensureNotMainThread
|
import com.futo.platformplayer.ensureNotMainThread
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.noise.protocol.CipherStatePair
|
import com.futo.platformplayer.noise.protocol.CipherStatePair
|
||||||
import com.futo.platformplayer.noise.protocol.DHState
|
import com.futo.platformplayer.noise.protocol.DHState
|
||||||
import com.futo.platformplayer.noise.protocol.HandshakeState
|
import com.futo.platformplayer.noise.protocol.HandshakeState
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StateSync
|
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.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
@@ -24,10 +28,9 @@ import java.nio.ByteOrder
|
|||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.zip.GZIPInputStream
|
||||||
import java.util.zip.GZIPOutputStream
|
import java.util.zip.GZIPOutputStream
|
||||||
import kotlin.math.min
|
|
||||||
import kotlin.system.measureTimeMillis
|
import kotlin.system.measureTimeMillis
|
||||||
import kotlin.time.measureTime
|
|
||||||
|
|
||||||
class SyncSocketSession {
|
class SyncSocketSession {
|
||||||
private val _socket: Socket
|
private val _socket: Socket
|
||||||
@@ -75,6 +78,11 @@ class SyncSocketSession {
|
|||||||
private val _pendingBulkGetRecordRequests = ConcurrentHashMap<Int, CompletableDeferred<Map<String, Pair<ByteArray, Long>>>>()
|
private val _pendingBulkGetRecordRequests = ConcurrentHashMap<Int, CompletableDeferred<Map<String, Pair<ByteArray, Long>>>>()
|
||||||
private val _pendingBulkConnectionInfoRequests = ConcurrentHashMap<Int, CompletableDeferred<Map<String, ConnectionInfo>>>()
|
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(
|
data class ConnectionInfo(
|
||||||
val port: UShort,
|
val port: UShort,
|
||||||
val name: String,
|
val name: String,
|
||||||
@@ -124,6 +132,7 @@ class SyncSocketSession {
|
|||||||
try {
|
try {
|
||||||
handshakeAsInitiator(remotePublicKey, appId, pairingCode)
|
handshakeAsInitiator(remotePublicKey, appId, pairingCode)
|
||||||
_onHandshakeComplete?.invoke(this)
|
_onHandshakeComplete?.invoke(this)
|
||||||
|
startPingLoop()
|
||||||
receiveLoop()
|
receiveLoop()
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to run as initiator", e)
|
Logger.e(TAG, "Failed to run as initiator", e)
|
||||||
@@ -138,6 +147,7 @@ class SyncSocketSession {
|
|||||||
try {
|
try {
|
||||||
handshakeAsInitiator(remotePublicKey, appId, pairingCode)
|
handshakeAsInitiator(remotePublicKey, appId, pairingCode)
|
||||||
_onHandshakeComplete?.invoke(this)
|
_onHandshakeComplete?.invoke(this)
|
||||||
|
startPingLoop()
|
||||||
receiveLoop()
|
receiveLoop()
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to run as initiator", e)
|
Logger.e(TAG, "Failed to run as initiator", e)
|
||||||
@@ -152,6 +162,7 @@ class SyncSocketSession {
|
|||||||
try {
|
try {
|
||||||
if (handshakeAsResponder()) {
|
if (handshakeAsResponder()) {
|
||||||
_onHandshakeComplete?.invoke(this)
|
_onHandshakeComplete?.invoke(this)
|
||||||
|
startPingLoop()
|
||||||
receiveLoop()
|
receiveLoop()
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -166,7 +177,7 @@ class SyncSocketSession {
|
|||||||
var totalBytesReceived: Int = 0
|
var totalBytesReceived: Int = 0
|
||||||
while (totalBytesReceived < size) {
|
while (totalBytesReceived < size) {
|
||||||
val bytesReceived = _inputStream.read(buffer, offset + totalBytesReceived, size - totalBytesReceived)
|
val bytesReceived = _inputStream.read(buffer, offset + totalBytesReceived, size - totalBytesReceived)
|
||||||
if (bytesReceived == 0)
|
if (bytesReceived <= 0)
|
||||||
throw Exception("Socket disconnected")
|
throw Exception("Socket disconnected")
|
||||||
totalBytesReceived += bytesReceived
|
totalBytesReceived += bytesReceived
|
||||||
}
|
}
|
||||||
@@ -240,7 +251,7 @@ class SyncSocketSession {
|
|||||||
private fun handshakeAsInitiator(remotePublicKey: String, appId: UInt, pairingCode: String?) {
|
private fun handshakeAsInitiator(remotePublicKey: String, appId: UInt, pairingCode: String?) {
|
||||||
performVersionCheck()
|
performVersionCheck()
|
||||||
|
|
||||||
val initiator = HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR)
|
val initiator = HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR)
|
||||||
initiator.localKeyPair.copyFrom(_localKeyPair)
|
initiator.localKeyPair.copyFrom(_localKeyPair)
|
||||||
initiator.remotePublicKey.setPublicKey(Base64.getDecoder().decode(remotePublicKey), 0)
|
initiator.remotePublicKey.setPublicKey(Base64.getDecoder().decode(remotePublicKey), 0)
|
||||||
initiator.start()
|
initiator.start()
|
||||||
@@ -288,13 +299,13 @@ class SyncSocketSession {
|
|||||||
_cipherStatePair = initiator.split()
|
_cipherStatePair = initiator.split()
|
||||||
val remoteKeyBytes = ByteArray(initiator.remotePublicKey.publicKeyLength)
|
val remoteKeyBytes = ByteArray(initiator.remotePublicKey.publicKeyLength)
|
||||||
initiator.remotePublicKey.getPublicKey(remoteKeyBytes, 0)
|
initiator.remotePublicKey.getPublicKey(remoteKeyBytes, 0)
|
||||||
_remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes)
|
_remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes).base64ToByteArray().toBase64()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handshakeAsResponder(): Boolean {
|
private fun handshakeAsResponder(): Boolean {
|
||||||
performVersionCheck()
|
performVersionCheck()
|
||||||
|
|
||||||
val responder = HandshakeState(StateSync.protocolName, HandshakeState.RESPONDER)
|
val responder = HandshakeState(SyncService.protocolName, HandshakeState.RESPONDER)
|
||||||
responder.localKeyPair.copyFrom(_localKeyPair)
|
responder.localKeyPair.copyFrom(_localKeyPair)
|
||||||
responder.start()
|
responder.start()
|
||||||
|
|
||||||
@@ -311,7 +322,7 @@ class SyncSocketSession {
|
|||||||
val appId = messageBuffer.int.toUInt()
|
val appId = messageBuffer.int.toUInt()
|
||||||
val pairingMessageLength = messageBuffer.int
|
val pairingMessageLength = messageBuffer.int
|
||||||
val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { messageBuffer.get(it) } else byteArrayOf()
|
val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { messageBuffer.get(it) } else byteArrayOf()
|
||||||
val mainLength = messageSize - 4 - 4 - pairingMessageLength
|
val mainLength = messageBuffer.remaining()
|
||||||
val mainMessage = ByteArray(mainLength).also { messageBuffer.get(it) }
|
val mainMessage = ByteArray(mainLength).also { messageBuffer.get(it) }
|
||||||
|
|
||||||
var pairingCode: String? = null
|
var pairingCode: String? = null
|
||||||
@@ -328,7 +339,7 @@ class SyncSocketSession {
|
|||||||
responder.readMessage(mainMessage, 0, mainLength, plaintext, 0)
|
responder.readMessage(mainMessage, 0, mainLength, plaintext, 0)
|
||||||
val remoteKeyBytes = ByteArray(responder.remotePublicKey.publicKeyLength)
|
val remoteKeyBytes = ByteArray(responder.remotePublicKey.publicKeyLength)
|
||||||
responder.remotePublicKey.getPublicKey(remoteKeyBytes, 0)
|
responder.remotePublicKey.getPublicKey(remoteKeyBytes, 0)
|
||||||
val remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes)
|
val remotePublicKey = remoteKeyBytes.toBase64()
|
||||||
|
|
||||||
val isAllowedToConnect = remotePublicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Direct, this, remotePublicKey, pairingCode, appId) ?: true)
|
val isAllowedToConnect = remotePublicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Direct, this, remotePublicKey, pairingCode, appId) ?: true)
|
||||||
if (!isAllowedToConnect) {
|
if (!isAllowedToConnect) {
|
||||||
@@ -342,12 +353,12 @@ class SyncSocketSession {
|
|||||||
_outputStream.write(responseBuffer, 0, 4 + responseLength)
|
_outputStream.write(responseBuffer, 0, 4 + responseLength)
|
||||||
|
|
||||||
_cipherStatePair = responder.split()
|
_cipherStatePair = responder.split()
|
||||||
_remotePublicKey = remotePublicKey
|
_remotePublicKey = remotePublicKey.base64ToByteArray().toBase64()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun performVersionCheck() {
|
private fun performVersionCheck() {
|
||||||
val CURRENT_VERSION = 4
|
val CURRENT_VERSION = 5
|
||||||
val MINIMUM_VERSION = 4
|
val MINIMUM_VERSION = 4
|
||||||
|
|
||||||
val versionBytes = ByteArray(4)
|
val versionBytes = ByteArray(4)
|
||||||
@@ -437,7 +448,7 @@ class SyncSocketSession {
|
|||||||
ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len)
|
ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len)
|
||||||
_outputStream.write(_sendBufferEncrypted, 0, 4 + len)
|
_outputStream.write(_sendBufferEncrypted, 0, 4 + len)
|
||||||
}
|
}
|
||||||
//Logger.v(TAG, "_outputStream.write (opcode: ${opcode}, subOpcode: ${subOpcode}, processedData.remaining(): ${processedData.remaining()}, sendDuration: ${sendDuration})")
|
Logger.v(TAG, "_outputStream.write (opcode: ${opcode}, subOpcode: ${subOpcode}, processedData.remaining(): ${processedData.remaining()}, sendDuration: ${sendDuration})")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,14 +458,14 @@ class SyncSocketSession {
|
|||||||
ensureNotMainThread()
|
ensureNotMainThread()
|
||||||
|
|
||||||
synchronized(_sendLockObject) {
|
synchronized(_sendLockObject) {
|
||||||
ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(2)
|
ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(HEADER_SIZE - 4)
|
||||||
_sendBuffer.asUByteArray()[4] = opcode
|
_sendBuffer.asUByteArray()[4] = opcode
|
||||||
_sendBuffer.asUByteArray()[5] = subOpcode
|
_sendBuffer.asUByteArray()[5] = subOpcode
|
||||||
_sendBuffer.asUByteArray()[6] = ContentEncoding.Raw.value
|
_sendBuffer.asUByteArray()[6] = ContentEncoding.Raw.value
|
||||||
|
|
||||||
//Logger.i(TAG, "Encrypting message (opcode = ${opcode}, subOpcode = ${subOpcode}, size = ${HEADER_SIZE})")
|
//Logger.i(TAG, "Encrypting message (opcode = ${opcode}, subOpcode = ${subOpcode}, size = ${HEADER_SIZE})")
|
||||||
|
|
||||||
val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 0, HEADER_SIZE)
|
val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 4, HEADER_SIZE)
|
||||||
//Logger.i(TAG, "Sending encrypted message (size = ${len})")
|
//Logger.i(TAG, "Sending encrypted message (size = ${len})")
|
||||||
|
|
||||||
ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len)
|
ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len)
|
||||||
@@ -518,7 +529,7 @@ class SyncSocketSession {
|
|||||||
val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode, appId) ?: true)
|
val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode, appId) ?: true)
|
||||||
if (!isAllowed) {
|
if (!isAllowed) {
|
||||||
val rp = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN)
|
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.putLong(connectionId)
|
||||||
rp.putInt(requestId)
|
rp.putInt(requestId)
|
||||||
rp.rewind()
|
rp.rewind()
|
||||||
@@ -828,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?) {
|
private fun handlePacket(opcode: UByte, subOpcode: UByte, d: ByteBuffer, contentEncoding: UByte, sourceChannel: ChannelRelayed?) {
|
||||||
Logger.i(TAG, "Handle packet (opcode = ${opcode}, subOpcode = ${subOpcode})")
|
Logger.i(TAG, "Handle packet (opcode = ${opcode}, subOpcode = ${subOpcode})")
|
||||||
|
|
||||||
@@ -837,12 +872,16 @@ class SyncSocketSession {
|
|||||||
if (!isGzipSupported)
|
if (!isGzipSupported)
|
||||||
throw Exception("Failed to handle packet, gzip is not supported for this opcode (opcode = ${opcode}, subOpcode = ${subOpcode}, data.length = ${data.remaining()}).")
|
throw Exception("Failed to handle packet, gzip is not supported for this opcode (opcode = ${opcode}, subOpcode = ${subOpcode}, data.length = ${data.remaining()}).")
|
||||||
|
|
||||||
val compressedStream = ByteArrayOutputStream()
|
val compressedStream = ByteArrayInputStream(data.array(), data.position(), data.remaining())
|
||||||
GZIPOutputStream(compressedStream).use { gzipStream ->
|
val outputStream = ByteArrayOutputStream()
|
||||||
gzipStream.write(data.array(), data.position(), data.remaining())
|
GZIPInputStream(compressedStream).use { gzipStream ->
|
||||||
gzipStream.finish()
|
val buffer = ByteArray(8192) // 8KB buffer
|
||||||
|
var bytesRead: Int
|
||||||
|
while (gzipStream.read(buffer).also { bytesRead = it } != -1) {
|
||||||
|
outputStream.write(buffer, 0, bytesRead)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
data = ByteBuffer.wrap(compressedStream.toByteArray())
|
data = ByteBuffer.wrap(outputStream.toByteArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
when (opcode) {
|
when (opcode) {
|
||||||
@@ -855,6 +894,11 @@ class SyncSocketSession {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
Opcode.PONG.value -> {
|
Opcode.PONG.value -> {
|
||||||
|
if (sourceChannel != null) {
|
||||||
|
sourceChannel.invokeDataHandler(opcode, subOpcode, data)
|
||||||
|
} else {
|
||||||
|
_lastPongTime = System.currentTimeMillis()
|
||||||
|
}
|
||||||
Logger.v(TAG, "Received pong")
|
Logger.v(TAG, "Received pong")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -929,7 +973,7 @@ class SyncSocketSession {
|
|||||||
throw Exception("After sync stream end, the stream must be complete")
|
throw Exception("After sync stream end, the stream must be complete")
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) }, contentEncoding, sourceChannel)
|
handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) }, syncStream.contentEncoding, sourceChannel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Opcode.DATA.value -> {
|
Opcode.DATA.value -> {
|
||||||
@@ -989,7 +1033,7 @@ class SyncSocketSession {
|
|||||||
suspend fun startRelayedChannel(publicKey: String, appId: UInt = 0u, pairingCode: String? = null): ChannelRelayed? {
|
suspend fun startRelayedChannel(publicKey: String, appId: UInt = 0u, pairingCode: String? = null): ChannelRelayed? {
|
||||||
val requestId = generateRequestId()
|
val requestId = generateRequestId()
|
||||||
val deferred = CompletableDeferred<ChannelRelayed>()
|
val deferred = CompletableDeferred<ChannelRelayed>()
|
||||||
val channel = ChannelRelayed(this, _localKeyPair, publicKey, true)
|
val channel = ChannelRelayed(this, _localKeyPair, publicKey.base64ToByteArray().toBase64(), true)
|
||||||
_onNewChannel?.invoke(this, channel)
|
_onNewChannel?.invoke(this, channel)
|
||||||
_pendingChannels[requestId] = channel to deferred
|
_pendingChannels[requestId] = channel to deferred
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.futo.platformplayer.views
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.text.TextWatcher
|
import android.text.TextWatcher
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
@@ -30,9 +32,26 @@ class SearchView : FrameLayout {
|
|||||||
textSearch = findViewById(R.id.edit_search)
|
textSearch = findViewById(R.id.edit_search)
|
||||||
buttonClear = findViewById(R.id.button_clear_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 {
|
textSearch.addTextChangedListener {
|
||||||
onSearchChanged.emit(it.toString());
|
buttonClear.visibility = if ((it?.length ?: 0) > 0) View.VISIBLE else View.GONE
|
||||||
|
onSearchChanged.emit(it.toString())
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,7 @@ import com.futo.platformplayer.casting.StateCasting
|
|||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
|
||||||
class DeviceViewHolder : ViewHolder {
|
class DeviceViewHolder : ViewHolder {
|
||||||
private val _layoutDevice: FrameLayout;
|
private val _layoutDevice: FrameLayout;
|
||||||
@@ -55,9 +56,17 @@ class DeviceViewHolder : ViewHolder {
|
|||||||
|
|
||||||
val connect = {
|
val connect = {
|
||||||
device?.let { dev ->
|
device?.let { dev ->
|
||||||
StateCasting.instance.activeDevice?.stopCasting();
|
if (dev.isReady) {
|
||||||
StateCasting.instance.connectDevice(dev);
|
StateCasting.instance.activeDevice?.stopCasting()
|
||||||
onConnect.emit(dev);
|
StateCasting.instance.connectDevice(dev)
|
||||||
|
onConnect.emit(dev)
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") }
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
//Ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +93,7 @@ class DeviceViewHolder : ViewHolder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_textName.text = d.name;
|
_textName.text = d.name;
|
||||||
_imageOnline.visibility = if (isOnlineDevice) View.VISIBLE else View.GONE
|
_imageOnline.visibility = if (isOnlineDevice && d.isReady) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
if (!d.isReady) {
|
if (!d.isReady) {
|
||||||
_imageLoader.visibility = View.GONE;
|
_imageLoader.visibility = View.GONE;
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import com.futo.platformplayer.R
|
|||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||||
import com.futo.platformplayer.models.HistoryVideo
|
import com.futo.platformplayer.models.HistoryVideo
|
||||||
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.toHumanNumber
|
import com.futo.platformplayer.toHumanNumber
|
||||||
import com.futo.platformplayer.toHumanTime
|
import com.futo.platformplayer.toHumanTime
|
||||||
import com.futo.platformplayer.views.others.ProgressBar
|
import com.futo.platformplayer.views.others.ProgressBar
|
||||||
|
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||||
|
|
||||||
class HistoryListViewHolder : ViewHolder {
|
class HistoryListViewHolder : ViewHolder {
|
||||||
private val _root: ConstraintLayout;
|
private val _root: ConstraintLayout;
|
||||||
@@ -30,6 +32,7 @@ class HistoryListViewHolder : ViewHolder {
|
|||||||
private val _imageRemove: ImageButton;
|
private val _imageRemove: ImageButton;
|
||||||
private val _textHeader: TextView;
|
private val _textHeader: TextView;
|
||||||
private val _timeBar: ProgressBar;
|
private val _timeBar: ProgressBar;
|
||||||
|
private val _thumbnailPlatform: PlatformIndicator
|
||||||
|
|
||||||
var video: HistoryVideo? = null
|
var video: HistoryVideo? = null
|
||||||
private set;
|
private set;
|
||||||
@@ -47,6 +50,7 @@ class HistoryListViewHolder : ViewHolder {
|
|||||||
_textVideoDuration = itemView.findViewById(R.id.thumbnail_duration);
|
_textVideoDuration = itemView.findViewById(R.id.thumbnail_duration);
|
||||||
_containerDuration = itemView.findViewById(R.id.thumbnail_duration_container);
|
_containerDuration = itemView.findViewById(R.id.thumbnail_duration_container);
|
||||||
_containerLive = itemView.findViewById(R.id.thumbnail_live_container);
|
_containerLive = itemView.findViewById(R.id.thumbnail_live_container);
|
||||||
|
_thumbnailPlatform = itemView.findViewById(R.id.thumbnail_platform)
|
||||||
_imageRemove = itemView.findViewById(R.id.image_trash);
|
_imageRemove = itemView.findViewById(R.id.image_trash);
|
||||||
_textHeader = itemView.findViewById(R.id.text_header);
|
_textHeader = itemView.findViewById(R.id.text_header);
|
||||||
_timeBar = itemView.findViewById(R.id.time_bar);
|
_timeBar = itemView.findViewById(R.id.time_bar);
|
||||||
@@ -73,6 +77,9 @@ class HistoryListViewHolder : ViewHolder {
|
|||||||
_textAuthor.text = v.video.author.name;
|
_textAuthor.text = v.video.author.name;
|
||||||
_textVideoDuration.text = v.video.duration.toHumanTime(false);
|
_textVideoDuration.text = v.video.duration.toHumanTime(false);
|
||||||
|
|
||||||
|
val pluginId = v.video.id.pluginId ?: StatePlatform.instance.getContentClientOrNull(v.video.url)?.id
|
||||||
|
_thumbnailPlatform.setPlatformFromClientID(pluginId)
|
||||||
|
|
||||||
if(v.video.isLive) {
|
if(v.video.isLive) {
|
||||||
_containerDuration.visibility = View.GONE;
|
_containerDuration.visibility = View.GONE;
|
||||||
_containerLive.visibility = View.VISIBLE;
|
_containerLive.visibility = View.VISIBLE;
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ open class PlaylistView : LinearLayout {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
currentPlaylist = null;
|
currentPlaylist = null;
|
||||||
_imageThumbnail.setImageResource(0);
|
_imageThumbnail.setImageDrawable(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.futo.platformplayer.views.adapters
|
package com.futo.platformplayer.views.adapters
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
|||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||||
import com.futo.platformplayer.states.StateDownloads
|
import com.futo.platformplayer.states.StateDownloads
|
||||||
|
import com.futo.platformplayer.states.StateHistory
|
||||||
import com.futo.platformplayer.toHumanNowDiffString
|
import com.futo.platformplayer.toHumanNowDiffString
|
||||||
import com.futo.platformplayer.toHumanNumber
|
import com.futo.platformplayer.toHumanNumber
|
||||||
import com.futo.platformplayer.toHumanTime
|
import com.futo.platformplayer.toHumanTime
|
||||||
|
import com.futo.platformplayer.views.others.ProgressBar
|
||||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||||
|
|
||||||
class VideoListEditorViewHolder : ViewHolder {
|
class VideoListEditorViewHolder : ViewHolder {
|
||||||
@@ -36,6 +38,7 @@ class VideoListEditorViewHolder : ViewHolder {
|
|||||||
private val _imageDragDrop: ImageButton;
|
private val _imageDragDrop: ImageButton;
|
||||||
private val _platformIndicator: PlatformIndicator;
|
private val _platformIndicator: PlatformIndicator;
|
||||||
private val _layoutDownloaded: FrameLayout;
|
private val _layoutDownloaded: FrameLayout;
|
||||||
|
private val _timeBar: ProgressBar
|
||||||
|
|
||||||
var video: IPlatformVideo? = null
|
var video: IPlatformVideo? = null
|
||||||
private set;
|
private set;
|
||||||
@@ -59,6 +62,7 @@ class VideoListEditorViewHolder : ViewHolder {
|
|||||||
_imageOptions = view.findViewById(R.id.image_settings);
|
_imageOptions = view.findViewById(R.id.image_settings);
|
||||||
_imageDragDrop = view.findViewById<ImageButton>(R.id.image_drag_drop);
|
_imageDragDrop = view.findViewById<ImageButton>(R.id.image_drag_drop);
|
||||||
_platformIndicator = view.findViewById(R.id.thumbnail_platform);
|
_platformIndicator = view.findViewById(R.id.thumbnail_platform);
|
||||||
|
_timeBar = view.findViewById(R.id.time_bar);
|
||||||
_layoutDownloaded = view.findViewById(R.id.layout_downloaded);
|
_layoutDownloaded = view.findViewById(R.id.layout_downloaded);
|
||||||
|
|
||||||
_imageDragDrop.setOnTouchListener { _, event ->
|
_imageDragDrop.setOnTouchListener { _, event ->
|
||||||
@@ -93,6 +97,9 @@ class VideoListEditorViewHolder : ViewHolder {
|
|||||||
_textAuthor.text = v.author.name;
|
_textAuthor.text = v.author.name;
|
||||||
_textVideoDuration.text = v.duration.toHumanTime(false);
|
_textVideoDuration.text = v.duration.toHumanTime(false);
|
||||||
|
|
||||||
|
val historyPosition = StateHistory.instance.getHistoryPosition(v.url)
|
||||||
|
_timeBar.progress = historyPosition.toFloat() / v.duration.toFloat();
|
||||||
|
|
||||||
if(v.isLive) {
|
if(v.isLive) {
|
||||||
_containerDuration.visibility = View.GONE;
|
_containerDuration.visibility = View.GONE;
|
||||||
_containerLive.visibility = View.VISIBLE;
|
_containerLive.visibility = View.VISIBLE;
|
||||||
|
|||||||
+2
@@ -79,6 +79,8 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
|||||||
return when(contentType) {
|
return when(contentType) {
|
||||||
ContentType.PLACEHOLDER -> createPlaceholderViewHolder(viewGroup);
|
ContentType.PLACEHOLDER -> createPlaceholderViewHolder(viewGroup);
|
||||||
ContentType.MEDIA -> createVideoPreviewViewHolder(viewGroup);
|
ContentType.MEDIA -> createVideoPreviewViewHolder(viewGroup);
|
||||||
|
ContentType.ARTICLE -> createPostViewHolder(viewGroup);
|
||||||
|
ContentType.WEB -> createPostViewHolder(viewGroup);
|
||||||
ContentType.POST -> createPostViewHolder(viewGroup);
|
ContentType.POST -> createPostViewHolder(viewGroup);
|
||||||
ContentType.PLAYLIST -> createPlaylistViewHolder(viewGroup);
|
ContentType.PLAYLIST -> createPlaylistViewHolder(viewGroup);
|
||||||
ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup);
|
ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup);
|
||||||
|
|||||||
+2
-2
@@ -76,8 +76,8 @@ class PreviewLockedView : LinearLayout {
|
|||||||
_textLockedUrl.text = content.unlockUrl ?: "";
|
_textLockedUrl.text = content.unlockUrl ?: "";
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
_imageChannelThumbnail.setImageResource(0);
|
_imageChannelThumbnail.setImageDrawable(null);
|
||||||
_imageVideoThumbnail.setImageResource(0);
|
_imageVideoThumbnail.setImageDrawable(null);
|
||||||
_textLockedDescription.text = "";
|
_textLockedDescription.text = "";
|
||||||
_textLockedUrl.text = "";
|
_textLockedUrl.text = "";
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-1
@@ -21,9 +21,11 @@ import com.futo.platformplayer.R
|
|||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
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.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
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.IPlatformPostDetails
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.constructs.TaskHandler
|
import com.futo.platformplayer.constructs.TaskHandler
|
||||||
import com.futo.platformplayer.dp
|
import com.futo.platformplayer.dp
|
||||||
@@ -141,6 +143,16 @@ class PreviewPostView : LinearLayout {
|
|||||||
content.content
|
content.content
|
||||||
else
|
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 "";
|
} else "";
|
||||||
|
|
||||||
if (content.name.isNullOrEmpty()) {
|
if (content.name.isNullOrEmpty()) {
|
||||||
@@ -154,7 +166,14 @@ class PreviewPostView : LinearLayout {
|
|||||||
|
|
||||||
if (content is IPlatformPost) {
|
if (content is IPlatformPost) {
|
||||||
setImages(content.thumbnails.filterNotNull());
|
setImages(content.thumbnails.filterNotNull());
|
||||||
} else {
|
}
|
||||||
|
else if(content is IPlatformArticle) {
|
||||||
|
if(content.thumbnails != null)
|
||||||
|
setImages(listOf(content.thumbnails!!));
|
||||||
|
else
|
||||||
|
setImages(null);
|
||||||
|
}
|
||||||
|
else {
|
||||||
setImages(null);
|
setImages(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -233,7 +233,7 @@ open class PreviewVideoView : LinearLayout {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
currentVideo = null;
|
currentVideo = null;
|
||||||
_imageVideo.setImageResource(0);
|
_imageVideo.setImageDrawable(null);
|
||||||
_containerDuration.visibility = GONE;
|
_containerDuration.visibility = GONE;
|
||||||
_containerLive.visibility = GONE;
|
_containerLive.visibility = GONE;
|
||||||
_timeBar?.visibility = GONE;
|
_timeBar?.visibility = GONE;
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@ class SubscriptionGroupBarViewHolder(private val _viewGroup: ViewGroup) : AnyAda
|
|||||||
if(img != null) {
|
if(img != null) {
|
||||||
img.setImageView(_image)
|
img.setImageView(_image)
|
||||||
} else {
|
} else {
|
||||||
_image.setImageResource(0);
|
_image.setImageDrawable(null);
|
||||||
|
|
||||||
if(value is SubscriptionGroup.Add)
|
if(value is SubscriptionGroup.Add)
|
||||||
_image.setBackgroundColor(Color.DKGRAY);
|
_image.setBackgroundColor(Color.DKGRAY);
|
||||||
|
|||||||
+1
-1
@@ -88,7 +88,7 @@ class SubscriptionGroupListViewHolder(private val _viewGroup: ViewGroup) : AnyAd
|
|||||||
if(img != null)
|
if(img != null)
|
||||||
img.setImageView(_image)
|
img.setImageView(_image)
|
||||||
else {
|
else {
|
||||||
_image.setImageResource(0);
|
_image.setImageDrawable(null);
|
||||||
|
|
||||||
if(value is SubscriptionGroup.Add)
|
if(value is SubscriptionGroup.Add)
|
||||||
_image.setBackgroundColor(Color.DKGRAY);
|
_image.setBackgroundColor(Color.DKGRAY);
|
||||||
|
|||||||
@@ -628,12 +628,12 @@ class GestureControlView : LinearLayout {
|
|||||||
private fun fastForwardTick() {
|
private fun fastForwardTick() {
|
||||||
_fastForwardCounter++;
|
_fastForwardCounter++;
|
||||||
|
|
||||||
val seekOffset: Long = 10000;
|
val seekOffset: Long = Settings.instance.playback.getSeekOffset();
|
||||||
if (_rewinding) {
|
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);
|
onSeek.emit(-seekOffset);
|
||||||
} else {
|
} else {
|
||||||
_textFastForward.text = "${_fastForwardCounter * 10} " + context.getString(R.string.seconds);
|
_textFastForward.text = "${_fastForwardCounter * seekOffset / 1_000} " + context.getString(R.string.seconds);
|
||||||
onSeek.emit(seekOffset);
|
onSeek.emit(seekOffset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -735,24 +735,43 @@ class GestureControlView : LinearLayout {
|
|||||||
_animatorBrightness?.start();
|
_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) {
|
fun setFullscreen(isFullScreen: Boolean) {
|
||||||
resetZoomPan()
|
resetZoomPan()
|
||||||
|
|
||||||
if (isFullScreen) {
|
if (isFullScreen) {
|
||||||
if (Settings.instance.gestureControls.useSystemBrightness) {
|
if (Settings.instance.gestureControls.useSystemBrightness) {
|
||||||
try {
|
saveBrightness()
|
||||||
_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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Settings.instance.gestureControls.useSystemVolume) {
|
if (Settings.instance.gestureControls.useSystemVolume) {
|
||||||
@@ -766,19 +785,7 @@ class GestureControlView : LinearLayout {
|
|||||||
onSoundAdjusted.emit(_soundFactor);
|
onSoundAdjusted.emit(_soundFactor);
|
||||||
} else {
|
} else {
|
||||||
if (Settings.instance.gestureControls.useSystemBrightness) {
|
if (Settings.instance.gestureControls.useSystemBrightness) {
|
||||||
if (Settings.instance.gestureControls.restoreSystemBrightness) {
|
restoreBrightness()
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
onBrightnessAdjusted.emit(1.0f);
|
onBrightnessAdjusted.emit(1.0f);
|
||||||
}
|
}
|
||||||
@@ -803,4 +810,4 @@ class GestureControlView : LinearLayout {
|
|||||||
const val EXIT_DURATION_FAST_FORWARD: Long = 600;
|
const val EXIT_DURATION_FAST_FORWARD: Long = 600;
|
||||||
const val TAG = "GestureControlView";
|
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.chapters.IChapter
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||||
import com.futo.platformplayer.casting.AirPlayCastingDevice
|
import com.futo.platformplayer.casting.AirPlayCastingDevice
|
||||||
|
import com.futo.platformplayer.casting.ChromecastCastingDevice
|
||||||
import com.futo.platformplayer.casting.StateCasting
|
import com.futo.platformplayer.casting.StateCasting
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
import com.futo.platformplayer.formatDuration
|
import com.futo.platformplayer.formatDuration
|
||||||
|
import com.futo.platformplayer.states.StateHistory
|
||||||
import com.futo.platformplayer.states.StatePlayer
|
import com.futo.platformplayer.states.StatePlayer
|
||||||
import com.futo.platformplayer.views.behavior.GestureControlView
|
import com.futo.platformplayer.views.behavior.GestureControlView
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -61,6 +64,7 @@ class CastView : ConstraintLayout {
|
|||||||
val onSettingsClick = Event0();
|
val onSettingsClick = Event0();
|
||||||
val onPrevious = Event0();
|
val onPrevious = Event0();
|
||||||
val onNext = Event0();
|
val onNext = Event0();
|
||||||
|
val onTimeJobTimeChanged_s = Event1<Long>()
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
|
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
|
||||||
@@ -185,11 +189,11 @@ class CastView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setIsPlaying(isPlaying: Boolean) {
|
fun setIsPlaying(isPlaying: Boolean) {
|
||||||
_updateTimeJob?.cancel();
|
stopTimeJob()
|
||||||
|
|
||||||
if(isPlaying) {
|
if(isPlaying) {
|
||||||
val d = StateCasting.instance.activeDevice;
|
val d = StateCasting.instance.activeDevice;
|
||||||
if (d is AirPlayCastingDevice) {
|
if (d is AirPlayCastingDevice || d is ChromecastCastingDevice) {
|
||||||
_updateTimeJob = _scope.launch {
|
_updateTimeJob = _scope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
val device = StateCasting.instance.activeDevice;
|
val device = StateCasting.instance.activeDevice;
|
||||||
@@ -198,7 +202,9 @@ class CastView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delay(1000);
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-8
@@ -28,17 +28,14 @@ class SlideUpMenuFilters {
|
|||||||
private var _changed: Boolean = false;
|
private var _changed: Boolean = false;
|
||||||
private val _lifecycleScope: CoroutineScope;
|
private val _lifecycleScope: CoroutineScope;
|
||||||
|
|
||||||
private var _isChannelSearch = false;
|
|
||||||
|
|
||||||
var commonCapabilities: ResultCapabilities? = null;
|
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;
|
_lifecycleScope = lifecycleScope;
|
||||||
_container = container;
|
_container = container;
|
||||||
_enabledClientsIds = enabledClientsIds;
|
_enabledClientsIds = enabledClientsIds;
|
||||||
_filterValues = filterValues;
|
_filterValues = filterValues;
|
||||||
_isChannelSearch = isChannelSearch;
|
|
||||||
_slideUpMenuOverlay = SlideUpMenuOverlay(_container.context, _container, container.context.getString(R.string.filters), container.context.getString(R.string.done), true, listOf());
|
_slideUpMenuOverlay = SlideUpMenuOverlay(_container.context, _container, container.context.getString(R.string.filters), container.context.getString(R.string.done), true, listOf());
|
||||||
_slideUpMenuOverlay.onOK.subscribe {
|
_slideUpMenuOverlay.onOK.subscribe {
|
||||||
onOK.emit(_enabledClientsIds, _changed);
|
onOK.emit(_enabledClientsIds, _changed);
|
||||||
@@ -51,10 +48,7 @@ class SlideUpMenuFilters {
|
|||||||
private fun updateCommonCapabilities() {
|
private fun updateCommonCapabilities() {
|
||||||
_lifecycleScope.launch(Dispatchers.IO) {
|
_lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val caps = if(!_isChannelSearch)
|
val caps = StatePlatform.instance.getCommonSearchCapabilities(_enabledClientsIds);
|
||||||
StatePlatform.instance.getCommonSearchCapabilities(_enabledClientsIds);
|
|
||||||
else
|
|
||||||
StatePlatform.instance.getCommonSearchChannelContentsCapabilities(_enabledClientsIds);
|
|
||||||
synchronized(_filterValues) {
|
synchronized(_filterValues) {
|
||||||
if (caps != null) {
|
if (caps != null) {
|
||||||
val keysToRemove = arrayListOf<String>();
|
val keysToRemove = arrayListOf<String>();
|
||||||
|
|||||||
@@ -9,17 +9,28 @@ class PlatformIndicator : androidx.appcompat.widget.AppCompatImageView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun clearPlatform() {
|
fun clearPlatform() {
|
||||||
setImageResource(0);
|
setImageDrawable(null);
|
||||||
}
|
}
|
||||||
fun setPlatformFromClientID(platformType : String?) {
|
fun setPlatformFromClientID(platformType : String?) {
|
||||||
if(platformType == null)
|
if(platformType == null)
|
||||||
setImageResource(0);
|
setImageDrawable(null);
|
||||||
else {
|
else {
|
||||||
val result = StatePlatform.instance.getPlatformIcon(platformType);
|
val result = StatePlatform.instance.getPlatformIcon(platformType);
|
||||||
if (result != null)
|
if (result != null)
|
||||||
result.setImageView(this);
|
result.setImageView(this);
|
||||||
else
|
else
|
||||||
setImageResource(0);
|
setImageDrawable(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun setPlatformFromClientName(name: String?) {
|
||||||
|
if(name == null)
|
||||||
|
setImageDrawable(null);
|
||||||
|
else {
|
||||||
|
val result = StatePlatform.instance.getPlatformIconByName(name);
|
||||||
|
if (result != null)
|
||||||
|
result.setImageView(this);
|
||||||
|
else
|
||||||
|
setImageDrawable(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -531,6 +531,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
fun setLoopVisible(visible: Boolean) {
|
fun setLoopVisible(visible: Boolean) {
|
||||||
_control_loop.visibility = if (visible) View.VISIBLE else View.GONE;
|
_control_loop.visibility = if (visible) View.VISIBLE else View.GONE;
|
||||||
_control_loop_fullscreen.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() {
|
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:isScrollContainer="true"
|
||||||
android:scrollbars="vertical"
|
android:scrollbars="vertical"
|
||||||
android:maxHeight="200dp"
|
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>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
android:id="@+id/button_buy_text"
|
android:id="@+id/button_buy_text"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="$9.99 + Tax"
|
android:text="$19 + Tax"
|
||||||
android:textSize="14dp"
|
android:textSize="14dp"
|
||||||
android:textColor="@color/white"
|
android:textColor="@color/white"
|
||||||
android:fontFamily="@font/inter_regular"
|
android:fontFamily="@font/inter_regular"
|
||||||
|
|||||||
@@ -94,6 +94,25 @@
|
|||||||
android:id="@+id/tags_text"
|
android:id="@+id/tags_text"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content" />
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/text_filters"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/filters"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
android:fontFamily="@font/inter_light"
|
||||||
|
android:textSize="16dp"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:paddingStart="5dp"
|
||||||
|
android:paddingTop="15dp"
|
||||||
|
android:paddingBottom="8dp" />
|
||||||
|
|
||||||
|
<com.futo.platformplayer.views.ToggleBar
|
||||||
|
android:id="@+id/toggle_bar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</androidx.appcompat.widget.Toolbar>
|
</androidx.appcompat.widget.Toolbar>
|
||||||
</com.google.android.material.appbar.AppBarLayout>
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|||||||
@@ -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>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user