mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Compare commits
167 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b93447f712 | |||
| 84a5103526 | |||
| c94c2721d7 | |||
| 0428c1191a | |||
| 8208f92802 | |||
| 3d33c4b8e0 | |||
| d3210ec12a | |||
| c959b973fc | |||
| 40c195d4a0 | |||
| f4f1470153 | |||
| 401999b5ea | |||
| 7b53315046 | |||
| 4d170db5e0 | |||
| fa8d175101 | |||
| cbef605f22 | |||
| cf95791dcc | |||
| 919567dbdb | |||
| 8ca317a38a | |||
| ccc686ed50 | |||
| e3e7b0c345 | |||
| 5b0f359944 | |||
| 29f1bef099 | |||
| 418f34c7e8 | |||
| 21c2ab21b2 | |||
| 1ace7318f3 | |||
| 48052b88db | |||
| 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 | |||
| 8202513993 | |||
| 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
|
||||
@@ -1,6 +1,9 @@
|
||||
name: Bug Report
|
||||
description: Let us know about an unexpected error, a crash, or an incorrect behavior.
|
||||
labels: ["Bug"]
|
||||
labels: ["Bug", "Android"]
|
||||
title: "Bug: "
|
||||
type: bug
|
||||
projects: ["futo-org/19"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@@ -18,11 +21,33 @@ body:
|
||||
* if you've found out a particular series of UI interactions can introduce buggy behavior, please label those steps 1-n with markdown
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
id: reproduction-steps
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: What did you expect to happen?
|
||||
placeholder: Tell us what you see!
|
||||
label: Reproduction steps
|
||||
description: Please provide us with the steps to reproduce the issue if possible. This step makes a big difference if we are going to be able to fix it so be as precise as possible.
|
||||
placeholder: |
|
||||
0. Play a Youtube video
|
||||
1. Press on Download button
|
||||
2. Select quality 1440p
|
||||
3. Grayjay crashes when attempting to download
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual-result
|
||||
attributes:
|
||||
label: Actual result
|
||||
description: What happend?
|
||||
placeholder: Tell us what you saw!
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-result
|
||||
attributes:
|
||||
label: Expected result
|
||||
description: What was suppose to happen?
|
||||
placeholder: Tell us what you expected to happen!
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -31,7 +56,7 @@ body:
|
||||
attributes:
|
||||
label: Grayjay Version
|
||||
description: In the application, select More > Settings, scroll to the bottom and locate the value next to "Version Name".
|
||||
placeholder: "242"
|
||||
placeholder: "311"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -42,19 +67,23 @@ body:
|
||||
multiple: true
|
||||
options:
|
||||
- "All"
|
||||
- "Youtube"
|
||||
- "Odysee"
|
||||
- "Rumble"
|
||||
- "Kick"
|
||||
- "Twitch"
|
||||
- "PeerTube"
|
||||
- "Patreon"
|
||||
- "Nebula"
|
||||
- "Apple Podcasts"
|
||||
- "BiliBili (CN)"
|
||||
- "Bitchute"
|
||||
- "SoundCloud"
|
||||
- "Crunchyroll"
|
||||
- "CuriosityStream"
|
||||
- "Dailymotion"
|
||||
- "Apple Podcasts"
|
||||
- "Kick"
|
||||
- "Nebula"
|
||||
- "Odysee"
|
||||
- "Patreon"
|
||||
- "PeerTube"
|
||||
- "Rumble"
|
||||
- "SoundCloud"
|
||||
- "Spotify"
|
||||
- "TedTalks"
|
||||
- "Twitch"
|
||||
- "Youtube"
|
||||
- "Other"
|
||||
validations:
|
||||
required: true
|
||||
@@ -66,6 +95,30 @@ body:
|
||||
description: In the application, select Sources > [the broken plugin], write down the value under "Version".
|
||||
placeholder: "12"
|
||||
|
||||
- type: input
|
||||
id: android-version
|
||||
attributes:
|
||||
label: Which android version are you using?
|
||||
placeholder: "Android 15"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: phone-model
|
||||
attributes:
|
||||
label: Which device are you using?
|
||||
placeholder: "Google Pixel 9"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os-version
|
||||
attributes:
|
||||
label: Which operating system are you using?
|
||||
placeholder: "GrapheneOS/CalyxOS/Tizen/HyperOS 2/..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: login
|
||||
attributes:
|
||||
@@ -86,9 +139,28 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: grayjay-references
|
||||
attributes:
|
||||
label: References
|
||||
description: |
|
||||
Are there any other GitHub issues, whether open or closed, that are related to the problem you've described above? If so, please create a list below that mentions each of them. For example:
|
||||
```
|
||||
- #10
|
||||
```
|
||||
placeholder:
|
||||
value:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Note:** If the submit button is disabled and you have filled out all required fields, please check that you did not forget a **Title** for the issue.
|
||||
+5
-2
@@ -1,13 +1,16 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or other enhancement.
|
||||
labels: ["Enhancement"]
|
||||
labels: ["Enhancement", "Android"]
|
||||
title: "Feature request: "
|
||||
type: feature
|
||||
projects: ["futo-org/19"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Thank you for opening a feature request.
|
||||
|
||||
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application
|
||||
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues and feature requests relating to the Grayjay android application
|
||||
|
||||
For discussion related to enhancements, please see: [The FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
|
||||
|
||||
+4
-1
@@ -1,13 +1,16 @@
|
||||
name: Documentation Issue
|
||||
description: Report an issue or suggest a change in the documentation.
|
||||
labels: ["Documentation"]
|
||||
title: "Documentation: "
|
||||
type: task
|
||||
projects: ["futo-org/19"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Thank you for opening a documentation change request.
|
||||
|
||||
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application. Use the `Documentation` issue type to report problems with the documentation in our code repositories, inside the application, or on [https://grayjay.app](https://grayjay.app)
|
||||
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay android application. Use the `Documentation` issue type to report problems with the documentation in our code repositories, inside the application, or on [https://grayjay.app](https://grayjay.app)
|
||||
Technical writers monitor this issue type, so report Grayjay bugs or feature requests with the `Bug report` or `Feature Request` issue types instead to get engineering attention.
|
||||
|
||||
For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
|
||||
+12
@@ -94,3 +94,15 @@
|
||||
[submodule "app/src/unstable/assets/sources/tedtalks"]
|
||||
path = app/src/unstable/assets/sources/tedtalks
|
||||
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 'com.google.android.flexbox:flexbox:3.0.0'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'com.arthenica:ffmpeg-kit-full:6.0-2.LTS'
|
||||
implementation fileTree(dir: 'aar', include: ['*.aar'])
|
||||
implementation 'com.arthenica:smart-exception-java:0.2.1'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
|
||||
implementation 'com.github.dhaval2404:imagepicker:2.1'
|
||||
implementation 'com.google.zxing:core:3.4.1'
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".activities.MainActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||
android:launchMode="singleInstance"
|
||||
@@ -239,4 +239,4 @@
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
</application>
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -32,7 +32,8 @@ let Type = {
|
||||
Text: {
|
||||
RAW: 0,
|
||||
HTML: 1,
|
||||
MARKUP: 2
|
||||
MARKUP: 2,
|
||||
CODE: 3
|
||||
},
|
||||
Chapter: {
|
||||
NORMAL: 0,
|
||||
@@ -291,15 +292,39 @@ class PlatformPostDetails extends PlatformPost {
|
||||
}
|
||||
}
|
||||
|
||||
class PlatformArticleDetails extends PlatformContent {
|
||||
class PlatformWeb extends PlatformContent {
|
||||
constructor(obj) {
|
||||
super(obj, 7);
|
||||
obj = obj ?? {};
|
||||
this.plugin_type = "PlatformWeb";
|
||||
}
|
||||
}
|
||||
class PlatformWebDetails extends PlatformWeb {
|
||||
constructor(obj) {
|
||||
super(obj, 7);
|
||||
obj = obj ?? {};
|
||||
this.plugin_type = "PlatformWebDetails";
|
||||
this.html = obj.html;
|
||||
}
|
||||
}
|
||||
|
||||
class PlatformArticle extends PlatformContent {
|
||||
constructor(obj) {
|
||||
super(obj, 3);
|
||||
obj = obj ?? {};
|
||||
this.plugin_type = "PlatformArticle";
|
||||
this.rating = obj.rating ?? new RatingLikes(-1);
|
||||
this.summary = obj.summary ?? "";
|
||||
this.thumbnails = obj.thumbnails ?? new Thumbnails([]);
|
||||
}
|
||||
}
|
||||
class PlatformArticleDetails extends PlatformArticle {
|
||||
constructor(obj) {
|
||||
super(obj, 3);
|
||||
obj = obj ?? {};
|
||||
this.plugin_type = "PlatformArticleDetails";
|
||||
this.rating = obj.rating ?? new RatingLikes(-1);
|
||||
this.summary = obj.summary ?? "";
|
||||
this.segments = obj.segments ?? [];
|
||||
this.thumbnails = obj.thumbnails ?? new Thumbnails([]);
|
||||
}
|
||||
}
|
||||
class ArticleSegment {
|
||||
@@ -315,9 +340,17 @@ class ArticleTextSegment extends ArticleSegment {
|
||||
}
|
||||
}
|
||||
class ArticleImagesSegment extends ArticleSegment {
|
||||
constructor(images) {
|
||||
constructor(images, caption) {
|
||||
super(2);
|
||||
this.images = images;
|
||||
this.caption = caption;
|
||||
}
|
||||
}
|
||||
class ArticleHeaderSegment extends ArticleSegment {
|
||||
constructor(content, level) {
|
||||
super(3);
|
||||
this.level = level;
|
||||
this.content = content;
|
||||
}
|
||||
}
|
||||
class ArticleNestedSegment extends ArticleSegment {
|
||||
@@ -595,6 +628,8 @@ class PlatformComment {
|
||||
this.date = obj.date ?? 0;
|
||||
this.replyCount = obj.replyCount ?? 0;
|
||||
this.context = obj.context ?? {};
|
||||
if(obj.getReplies)
|
||||
this.getReplies = obj.getReplies;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -399,9 +399,11 @@ fun String.matchesDomain(queryDomain: String): Boolean {
|
||||
|
||||
fun String.getSubdomainWildcardQuery(): String {
|
||||
val domainParts = this.split(".");
|
||||
val sldParts = "." + domainParts[domainParts.size - 2].lowercase() + "." + domainParts[domainParts.size - 1].lowercase();
|
||||
if(slds.contains(sldParts))
|
||||
return "." + domainParts.drop(domainParts.size - 3).joinToString(".");
|
||||
var wildcardDomain = if(domainParts.size > 2)
|
||||
"." + domainParts.drop(1).joinToString(".")
|
||||
else
|
||||
return "." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||
"." + domainParts.joinToString(".");
|
||||
if(slds.contains(wildcardDomain.lowercase()))
|
||||
"." + domainParts.joinToString(".");
|
||||
return wildcardDomain;
|
||||
}
|
||||
@@ -217,9 +217,9 @@ private fun ByteArray.toInetAddress(): InetAddress {
|
||||
}
|
||||
|
||||
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? {
|
||||
val timeout = 2000
|
||||
|
||||
ensureNotMainThread()
|
||||
|
||||
val timeout = 10000
|
||||
val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance<Inet4Address>() else attemptAddresses;
|
||||
if(addresses.isEmpty())
|
||||
throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})");
|
||||
|
||||
@@ -7,6 +7,9 @@ import java.net.InetAddress
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URLEncoder
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
//Syntax sugaring
|
||||
inline fun <reified T> Any.assume(): T?{
|
||||
@@ -33,13 +36,37 @@ fun Boolean?.toYesNo(): String {
|
||||
fun InetAddress?.toUrlAddress(): String {
|
||||
return when (this) {
|
||||
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 -> {
|
||||
hostAddress
|
||||
this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
|
||||
}
|
||||
else -> {
|
||||
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)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import com.futo.platformplayer.states.StateUpdate
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.FragmentedStorageFileJson
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.fields.AdvancedField
|
||||
import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
@@ -175,6 +176,10 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}*/
|
||||
|
||||
|
||||
@FormField(R.string.advanced_settings, FieldForm.TOGGLE, R.string.advanced_settings_description, -1, "advancedSettings")
|
||||
var advancedSettings: Boolean = false;
|
||||
|
||||
@FormField(R.string.language, "group", -1, 0)
|
||||
var language = LanguageSettings();
|
||||
@Serializable
|
||||
@@ -221,10 +226,11 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.show_home_filters_plugin_names, FieldForm.TOGGLE, R.string.show_home_filters_plugin_names_description, 5)
|
||||
var showHomeFiltersPluginNames: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
||||
var previewFeedItems: Boolean = true;
|
||||
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||
var progressBar: Boolean = true;
|
||||
|
||||
@@ -253,9 +259,11 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@DropdownFieldOptionsId(R.array.feed_style)
|
||||
var searchFeedStyle: Int = 1;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
|
||||
var previewFeedItems: Boolean = true;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||
var progressBar: Boolean = true;
|
||||
|
||||
@@ -277,6 +285,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@Serializable
|
||||
class ChannelSettings {
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||
var progressBar: Boolean = true;
|
||||
}
|
||||
@@ -302,16 +311,20 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6)
|
||||
var useSubscriptionExchange: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
||||
var previewFeedItems: Boolean = true;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 7)
|
||||
var progressBar: Boolean = true;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 8)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var fetchOnAppBoot: Boolean = true;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 9)
|
||||
var fetchOnTabOpen: Boolean = true;
|
||||
|
||||
@@ -342,13 +355,16 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 12)
|
||||
var showWatchMetrics: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 13)
|
||||
var allowPlaytimeTracking: Boolean = true;
|
||||
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 14)
|
||||
var alwaysReloadFromCache: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.peek_channel_contents, FieldForm.TOGGLE, R.string.peek_channel_contents_description, 15)
|
||||
var peekChannelContents: Boolean = false;
|
||||
|
||||
@@ -425,9 +441,11 @@ class Settings : FragmentedStorageFileJson() {
|
||||
var preferredPreviewQuality: Int = 5;
|
||||
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.simplify_sources, FieldForm.TOGGLE, R.string.simplify_sources_description, 4)
|
||||
var simplifySources: Boolean = true;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.always_allow_reverse_landscape_auto_rotate, FieldForm.TOGGLE, R.string.always_allow_reverse_landscape_auto_rotate_description, 5)
|
||||
var alwaysAllowReverseLandscapeAutoRotate: Boolean = true
|
||||
|
||||
@@ -438,6 +456,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
fun isBackgroundContinue() = backgroundPlay == 1;
|
||||
fun isBackgroundPictureInPicture() = backgroundPlay == 2;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.resume_after_preview, FieldForm.DROPDOWN, R.string.when_watching_a_video_in_preview_mode_resume_at_the_position_when_opening_the_video_code, 7)
|
||||
@DropdownFieldOptionsId(R.array.resume_after_preview)
|
||||
var resumeAfterPreview: Int = 1;
|
||||
@@ -464,14 +483,10 @@ class Settings : FragmentedStorageFileJson() {
|
||||
};
|
||||
}
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 9)
|
||||
var useLiveChatWindow: Boolean = true;
|
||||
|
||||
|
||||
|
||||
@FormField(R.string.background_switch_audio, FieldForm.TOGGLE, R.string.background_switch_audio_description, 10)
|
||||
var backgroundSwitchToAudio: Boolean = true;
|
||||
|
||||
@FormField(R.string.restart_after_audio_focus_loss, FieldForm.DROPDOWN, R.string.restart_playback_when_gaining_audio_focus_after_a_loss, 11)
|
||||
@DropdownFieldOptionsId(R.array.restart_playback_after_loss)
|
||||
var restartPlaybackAfterLoss: Int = 1;
|
||||
@@ -497,8 +512,25 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.autoplay, FieldForm.TOGGLE, R.string.autoplay, 21)
|
||||
var autoplay: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22)
|
||||
var deleteFromWatchLaterAuto: Boolean = true;
|
||||
|
||||
@FormField(R.string.seek_offset, FieldForm.DROPDOWN, R.string.seek_offset_description, 23)
|
||||
@DropdownFieldOptionsId(R.array.seek_offset_duration)
|
||||
var seekOffset: Int = 2;
|
||||
|
||||
fun getSeekOffset(): Long {
|
||||
return when(seekOffset) {
|
||||
0 -> 3_000L;
|
||||
1 -> 5_000L;
|
||||
2 -> 10_000L;
|
||||
3 -> 20_000L;
|
||||
4 -> 30_000L;
|
||||
5 -> 60_000L;
|
||||
else -> 10_000L;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
||||
@@ -514,6 +546,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.default_recommendations, FieldForm.TOGGLE, R.string.default_recommendations_description, 0)
|
||||
var recommendationsDefault: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.hide_recommendations, FieldForm.TOGGLE, R.string.hide_recommendations_description, 0)
|
||||
var hideRecommendations: Boolean = false;
|
||||
|
||||
@@ -550,10 +583,12 @@ class Settings : FragmentedStorageFileJson() {
|
||||
var preferredAudioQuality: Int = 1;
|
||||
fun isHighBitrateDefault(): Boolean = preferredAudioQuality > 0;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.byte_range_download, FieldForm.TOGGLE, R.string.attempt_to_utilize_byte_ranges, 4)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var byteRangeDownload: Boolean = true;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.byte_range_concurrency, FieldForm.DROPDOWN, R.string.number_of_concurrent_threads_to_multiply_download_speeds_from_throttled_sources, 5)
|
||||
@DropdownFieldOptionsId(R.array.thread_count)
|
||||
var byteRangeConcurrency: Int = 3;
|
||||
@@ -583,14 +618,15 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var keepScreenOn: Boolean = true;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 3)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var alwaysProxyRequests: Boolean = false;
|
||||
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var allowIpv6: Boolean = false;
|
||||
var allowIpv6: Boolean = true;
|
||||
|
||||
/*TODO: Should we have a different casting quality?
|
||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||
@@ -659,9 +695,11 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@Serializable
|
||||
class Plugins {
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
|
||||
var checkDisabledPluginsForUpdates: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
|
||||
var clearCookiesOnLogout: Boolean = true;
|
||||
|
||||
@@ -862,7 +900,23 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 1)
|
||||
val paymentStatus: String get() = SettingsActivity.getActivity()?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown";
|
||||
|
||||
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 2)
|
||||
@FormField(R.string.license_status, FieldForm.BUTTON, R.string.view_license_status, 2)
|
||||
fun viewLicenseStatus() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
try {
|
||||
if (StatePayment.instance.hasPaid) {
|
||||
val paymentKey = StatePayment.instance.getPaymentKey()
|
||||
UIDialogs.showDialogOk(it, R.drawable.ic_paid, "License activated\n" + paymentKey.first)
|
||||
} else {
|
||||
UIDialogs.showDialogOk(it, R.drawable.ic_paid, "No license activated")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to show license status dialog", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 3)
|
||||
fun clearPayment() {
|
||||
SettingsActivity.getActivity()?.let { context ->
|
||||
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete your license?", {
|
||||
@@ -880,8 +934,10 @@ class Settings : FragmentedStorageFileJson() {
|
||||
var other = Other();
|
||||
@Serializable
|
||||
class Other {
|
||||
@AdvancedField
|
||||
@FormField(R.string.playlist_delete_confirmation, FieldForm.TOGGLE, R.string.playlist_delete_confirmation_description, 2)
|
||||
var playlistDeleteConfirmation: Boolean = true;
|
||||
@AdvancedField
|
||||
@FormField(R.string.playlist_allow_dups, FieldForm.TOGGLE, R.string.playlist_allow_dups_description, 3)
|
||||
var playlistAllowDups: Boolean = true;
|
||||
|
||||
@@ -926,7 +982,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@Serializable
|
||||
class Synchronization {
|
||||
@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)
|
||||
var broadcast: Boolean = false;
|
||||
@@ -948,6 +1004,9 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(R.string.connect_local_direct_through_relay, FieldForm.TOGGLE, R.string.connect_local_direct_through_relay_description, 3)
|
||||
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)
|
||||
@@ -1016,4 +1075,4 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,7 +319,11 @@ class UIDialogs {
|
||||
closeAction?.invoke()
|
||||
}, UIDialogs.ActionStyle.NONE),
|
||||
UIDialogs.Action(context.getString(R.string.retry), {
|
||||
retryAction?.invoke();
|
||||
try {
|
||||
retryAction?.invoke();
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception retrying", e)
|
||||
}
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
else
|
||||
@@ -333,7 +337,11 @@ class UIDialogs {
|
||||
closeAction?.invoke()
|
||||
}, UIDialogs.ActionStyle.NONE),
|
||||
UIDialogs.Action(context.getString(R.string.retry), {
|
||||
retryAction?.invoke();
|
||||
try {
|
||||
retryAction?.invoke();
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception retrying", e)
|
||||
}
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,14 @@ import android.app.NotificationManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory
|
||||
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
|
||||
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
@@ -37,6 +43,9 @@ import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.models.SubscriptionGroup
|
||||
import com.futo.platformplayer.parsers.HLS
|
||||
import com.futo.platformplayer.parsers.HLS.MediaRendition
|
||||
import com.futo.platformplayer.parsers.HLS.StreamInfo
|
||||
import com.futo.platformplayer.parsers.HLS.VariantPlaylistReference
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
@@ -63,6 +72,8 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayInputStream
|
||||
import androidx.core.net.toUri
|
||||
|
||||
class UISlideOverlays {
|
||||
companion object {
|
||||
@@ -299,6 +310,7 @@ class UISlideOverlays {
|
||||
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
|
||||
val items = arrayListOf<View>(LoaderView(container.context))
|
||||
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
|
||||
@@ -310,6 +322,8 @@ class UISlideOverlays {
|
||||
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||
?: throw Exception("Master playlist content is empty")
|
||||
|
||||
val resolvedPlaylistUrl = masterPlaylistResponse.url
|
||||
|
||||
val videoButtons = arrayListOf<SlideUpMenuItem>()
|
||||
val audioButtons = arrayListOf<SlideUpMenuItem>()
|
||||
//TODO: Implement subtitles
|
||||
@@ -322,55 +336,103 @@ class UISlideOverlays {
|
||||
|
||||
val masterPlaylist: HLS.MasterPlaylist
|
||||
try {
|
||||
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl)
|
||||
val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray())
|
||||
val playlist = DefaultHlsPlaylistParserFactory().createPlaylistParser()
|
||||
.parse(sourceUrl.toUri(), inputStream)
|
||||
|
||||
masterPlaylist.getAudioSources().forEach { it ->
|
||||
if (playlist is HlsMediaPlaylist) {
|
||||
if (source is IHLSManifestAudioSource) {
|
||||
val variant = HLS.mediaRenditionToVariant(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null))!!
|
||||
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
audioButtons.add(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_music,
|
||||
it.name,
|
||||
listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
|
||||
(prefix + it.codec).trim(),
|
||||
tag = it,
|
||||
call = {
|
||||
selectedAudioVariant = it
|
||||
slideUpMenuOverlay.selectOption(audioButtons, it)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
}
|
||||
|
||||
/*masterPlaylist.getSubtitleSources().forEach { it ->
|
||||
subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
|
||||
selectedSubtitleVariant = it
|
||||
slideUpMenuOverlay.selectOption(subtitleButtons, it)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
}, false))
|
||||
}*/
|
||||
|
||||
masterPlaylist.getVideoSources().forEach {
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
videoButtons.add(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
it.name,
|
||||
"${it.width}x${it.height}",
|
||||
(prefix + it.codec).trim(),
|
||||
tag = it,
|
||||
call = {
|
||||
selectedVideoVariant = it
|
||||
slideUpMenuOverlay.selectOption(videoButtons, it)
|
||||
if (audioButtons.isEmpty()){
|
||||
val estSize = VideoHelper.estimateSourceSize(variant);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
audioButtons.add(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_music,
|
||||
variant.name,
|
||||
listOf(variant.language, variant.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
|
||||
(prefix + variant.codec).trim(),
|
||||
tag = variant,
|
||||
call = {
|
||||
selectedAudioVariant = variant
|
||||
slideUpMenuOverlay.selectOption(audioButtons, variant)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
}
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
} else {
|
||||
val variant = HLS.variantReferenceToVariant(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null)))
|
||||
|
||||
val estSize = VideoHelper.estimateSourceSize(variant);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
videoButtons.add(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
variant.name,
|
||||
"${variant.width}x${variant.height}",
|
||||
(prefix + variant.codec).trim(),
|
||||
tag = variant,
|
||||
call = {
|
||||
selectedVideoVariant = variant
|
||||
slideUpMenuOverlay.selectOption(videoButtons, variant)
|
||||
if (audioButtons.isEmpty()){
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
}
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
}
|
||||
} else if (playlist is HlsMultivariantPlaylist) {
|
||||
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, resolvedPlaylistUrl)
|
||||
|
||||
masterPlaylist.getAudioSources().forEach { it ->
|
||||
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
audioButtons.add(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_music,
|
||||
it.name,
|
||||
listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
|
||||
(prefix + it.codec).trim(),
|
||||
tag = it,
|
||||
call = {
|
||||
selectedAudioVariant = it
|
||||
slideUpMenuOverlay.selectOption(audioButtons, it)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
}
|
||||
|
||||
/*masterPlaylist.getSubtitleSources().forEach { it ->
|
||||
subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
|
||||
selectedSubtitleVariant = it
|
||||
slideUpMenuOverlay.selectOption(subtitleButtons, it)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
}, false))
|
||||
}*/
|
||||
|
||||
masterPlaylist.getVideoSources().forEach {
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
videoButtons.add(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
it.name,
|
||||
"${it.width}x${it.height}",
|
||||
(prefix + it.codec).trim(),
|
||||
tag = it,
|
||||
call = {
|
||||
selectedVideoVariant = it
|
||||
slideUpMenuOverlay.selectOption(videoButtons, it)
|
||||
if (audioButtons.isEmpty()){
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
}
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
val newItems = arrayListOf<View>()
|
||||
@@ -398,11 +460,11 @@ class UISlideOverlays {
|
||||
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (source is IHLSManifestSource) {
|
||||
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, sourceUrl), null, null)
|
||||
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null)
|
||||
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
|
||||
slideUpMenuOverlay.hide()
|
||||
} else if (source is IHLSManifestAudioSource) {
|
||||
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, sourceUrl), null)
|
||||
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null)
|
||||
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
|
||||
slideUpMenuOverlay.hide()
|
||||
} else {
|
||||
@@ -984,26 +1046,30 @@ class UISlideOverlays {
|
||||
+ actions).filterNotNull()
|
||||
));
|
||||
items.add(
|
||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto",
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuGroup(
|
||||
container.context, container.context.getString(R.string.add_to), "addto",
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_queue_add,
|
||||
container.context.getString(R.string.add_to_queue),
|
||||
"${queue.size} " + container.context.getString(R.string.videos),
|
||||
tag = "queue",
|
||||
call = { StatePlayer.instance.addToQueue(video); }),
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_watchlist_add,
|
||||
"${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "",
|
||||
"${watchLater.size} " + container.context.getString(R.string.videos),
|
||||
tag = "watch later",
|
||||
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }),
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_history,
|
||||
container.context.getString(R.string.add_to_history),
|
||||
"Mark as watched",
|
||||
tag = "history",
|
||||
call = { StateHistory.instance.markAsWatched(video); }),
|
||||
));
|
||||
));
|
||||
|
||||
val playlistItems = arrayListOf<SlideUpMenuItem>();
|
||||
playlistItems.add(SlideUpMenuItem(
|
||||
@@ -1067,14 +1133,17 @@ class UISlideOverlays {
|
||||
val queue = StatePlayer.instance.getQueue();
|
||||
val watchLater = StatePlaylists.instance.getWatchLater();
|
||||
items.add(
|
||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other",
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuGroup(
|
||||
container.context, container.context.getString(R.string.other), "other",
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_queue_add,
|
||||
container.context.getString(R.string.queue),
|
||||
"${queue.size} " + container.context.getString(R.string.videos),
|
||||
tag = "queue",
|
||||
call = { StatePlayer.instance.addToQueue(video); }),
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_watchlist_add,
|
||||
StatePlayer.TYPE_WATCHLATER,
|
||||
"${watchLater.size} " + container.context.getString(R.string.videos),
|
||||
@@ -1083,7 +1152,7 @@ class UISlideOverlays {
|
||||
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true))
|
||||
UIDialogs.appToast("Added to watch later", false);
|
||||
}),
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
val playlistItems = arrayListOf<SlideUpMenuItem>();
|
||||
@@ -1121,8 +1190,8 @@ class UISlideOverlays {
|
||||
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() };
|
||||
}
|
||||
|
||||
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>, isChannelSearch: Boolean = false): SlideUpMenuFilters {
|
||||
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues, isChannelSearch);
|
||||
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>): SlideUpMenuFilters {
|
||||
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues);
|
||||
overlay.show();
|
||||
return overlay;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
import java.net.InterfaceAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.SocketException
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.SecureRandom
|
||||
import java.time.OffsetDateTime
|
||||
@@ -331,4 +337,125 @@ fun ByteArray.fromGzip(): ByteArray {
|
||||
}
|
||||
}
|
||||
return outputStream.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
fun findCandidateAddresses(): List<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
|
||||
.sortedWith(
|
||||
compareBy<Pair<NetworkInterface, InterfaceAddress>>(
|
||||
{ addressScore(it.second.address) },
|
||||
{ interfaceScore(it.first) },
|
||||
{ -it.second.networkPrefixLength.toInt() },
|
||||
{ -it.first.mtu }
|
||||
)
|
||||
).map { it.second.address }
|
||||
}
|
||||
|
||||
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.app.AlertDialog
|
||||
import android.content.ComponentName
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.StrictMode
|
||||
import android.os.StrictMode.VmPolicy
|
||||
@@ -22,6 +22,7 @@ import android.widget.ImageView
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.constraintlayout.motion.widget.MotionLayout
|
||||
import androidx.core.app.ActivityCompat
|
||||
@@ -31,6 +32,8 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentContainerView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.whenStateAtLeast
|
||||
import androidx.lifecycle.withStateAtLeast
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.R
|
||||
@@ -39,7 +42,9 @@ import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ArticleDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.BuyFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
|
||||
@@ -66,7 +71,9 @@ import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragm
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.TutorialFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment.State
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.WebDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
|
||||
@@ -75,7 +82,6 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImportCache
|
||||
import com.futo.platformplayer.models.UrlVideoWithTime
|
||||
import com.futo.platformplayer.receivers.MediaButtonReceiver
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateBackup
|
||||
@@ -147,6 +153,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
//Frags Main
|
||||
lateinit var _fragMainHome: HomeFragment;
|
||||
lateinit var _fragPostDetail: PostDetailFragment;
|
||||
lateinit var _fragArticleDetail: ArticleDetailFragment;
|
||||
lateinit var _fragWebDetail: WebDetailFragment;
|
||||
lateinit var _fragMainVideoSearchResults: ContentSearchResultsFragment;
|
||||
lateinit var _fragMainCreatorSearchResults: CreatorSearchResultsFragment;
|
||||
lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment;
|
||||
@@ -176,7 +184,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
lateinit var _fragVideoDetail: VideoDetailFragment;
|
||||
|
||||
//State
|
||||
private val _queue: Queue<Pair<MainFragment, Any?>> = LinkedList();
|
||||
private val _queue: LinkedList<Pair<MainFragment, Any?>> = LinkedList();
|
||||
lateinit var fragCurrent: MainFragment private set;
|
||||
private var _parameterCurrent: Any? = null;
|
||||
|
||||
@@ -186,6 +194,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
private var _isVisible = true;
|
||||
private var _wasStopped = false;
|
||||
private var _privateModeEnabled = false
|
||||
private var _pictureInPictureEnabled = false
|
||||
private var _isFullscreen = false
|
||||
|
||||
private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||
@@ -197,7 +208,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
lifecycleScope.launch {
|
||||
handleUrlAll(content)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -263,6 +274,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
StateApp.instance.mainAppStarting(this);
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val uiMode = getSystemService(UiModeManager::class.java)
|
||||
uiMode.setApplicationNightMode(UiModeManager.MODE_NIGHT_YES)
|
||||
}
|
||||
setContentView(R.layout.activity_main);
|
||||
setNavigationBarColorAndIcons();
|
||||
if (Settings.instance.playback.allowVideoToGoUnderCutout)
|
||||
@@ -270,7 +285,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
|
||||
runBlocking {
|
||||
StatePlatform.instance.updateAvailableClients(this@MainActivity);
|
||||
try {
|
||||
StatePlatform.instance.updateAvailableClients(this@MainActivity);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception in updateAvailableClients", e)
|
||||
}
|
||||
}
|
||||
|
||||
//Preload common files to memory
|
||||
@@ -314,6 +333,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragMainPlaylist = PlaylistFragment.newInstance();
|
||||
_fragMainRemotePlaylist = RemotePlaylistFragment.newInstance();
|
||||
_fragPostDetail = PostDetailFragment.newInstance();
|
||||
_fragArticleDetail = ArticleDetailFragment.newInstance();
|
||||
_fragWebDetail = WebDetailFragment.newInstance();
|
||||
_fragWatchlist = WatchLaterFragment.newInstance();
|
||||
_fragHistory = HistoryFragment.newInstance();
|
||||
_fragSourceDetail = SourceDetailFragment.newInstance();
|
||||
@@ -355,22 +376,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragMainSubscriptionsFeed.setPreviewsEnabled(true);
|
||||
_fragContainerVideoDetail.visibility = View.INVISIBLE;
|
||||
updateSegmentPaddings();
|
||||
updatePrivateModeVisibility()
|
||||
};
|
||||
|
||||
|
||||
_buttonIncognito = findViewById(R.id.incognito_button);
|
||||
_buttonIncognito.elevation = -99f;
|
||||
_buttonIncognito.alpha = 0f;
|
||||
updatePrivateModeVisibility()
|
||||
StateApp.instance.privateModeChanged.subscribe {
|
||||
//Messing with visibility causes some issues with layout ordering?
|
||||
if (it) {
|
||||
_buttonIncognito.elevation = 99f;
|
||||
_buttonIncognito.alpha = 1f;
|
||||
} else {
|
||||
_buttonIncognito.elevation = -99f;
|
||||
_buttonIncognito.alpha = 0f;
|
||||
}
|
||||
_privateModeEnabled = it
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
_buttonIncognito.setOnClickListener {
|
||||
if (!StateApp.instance.privateMode)
|
||||
return@setOnClickListener;
|
||||
@@ -387,19 +404,16 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
};
|
||||
_fragVideoDetail.onFullscreenChanged.subscribe {
|
||||
Logger.i(TAG, "onFullscreenChanged ${it}");
|
||||
_isFullscreen = it
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
if (it) {
|
||||
_buttonIncognito.elevation = -99f;
|
||||
_buttonIncognito.alpha = 0f;
|
||||
} else {
|
||||
if (StateApp.instance.privateMode) {
|
||||
_buttonIncognito.elevation = 99f;
|
||||
_buttonIncognito.alpha = 1f;
|
||||
} else {
|
||||
_buttonIncognito.elevation = -99f;
|
||||
_buttonIncognito.alpha = 0f;
|
||||
}
|
||||
}
|
||||
_fragVideoDetail.onMinimize.subscribe {
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
_fragVideoDetail.onMaximized.subscribe {
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
StatePlayer.instance.also {
|
||||
@@ -447,6 +461,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragMainPlaylist.topBar = _fragTopBarNavigation;
|
||||
_fragMainRemotePlaylist.topBar = _fragTopBarNavigation;
|
||||
_fragPostDetail.topBar = _fragTopBarNavigation;
|
||||
_fragArticleDetail.topBar = _fragTopBarNavigation;
|
||||
_fragWebDetail.topBar = _fragTopBarNavigation;
|
||||
_fragWatchlist.topBar = _fragTopBarNavigation;
|
||||
_fragHistory.topBar = _fragTopBarNavigation;
|
||||
_fragSourceDetail.topBar = _fragTopBarNavigation;
|
||||
@@ -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() {
|
||||
super.onResume();
|
||||
Logger.v(TAG, "onResume")
|
||||
@@ -692,7 +720,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
"VIDEO" -> {
|
||||
val url = intent.getStringExtra("VIDEO");
|
||||
navigate(_fragVideoDetail, url);
|
||||
navigateWhenReady(_fragVideoDetail, url);
|
||||
}
|
||||
|
||||
"IMPORT_OPTIONS" -> {
|
||||
@@ -710,11 +738,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
"Sources" -> {
|
||||
runBlocking {
|
||||
StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed..
|
||||
navigate(_fragMainSources);
|
||||
navigateWhenReady(_fragMainSources);
|
||||
}
|
||||
};
|
||||
"BROWSE_PLUGINS" -> {
|
||||
navigate(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
|
||||
navigateWhenReady(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
|
||||
Pair("grayjay") { req ->
|
||||
StateApp.instance.contextOrNull?.let {
|
||||
if (it is MainActivity) {
|
||||
@@ -732,8 +760,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
try {
|
||||
if (targetData != null) {
|
||||
runBlocking {
|
||||
handleUrlAll(targetData)
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
handleUrlAll(targetData)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception in handleUrlAll", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
@@ -761,10 +793,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
startActivity(intent);
|
||||
} else if (url.startsWith("grayjay://video/")) {
|
||||
val videoUrl = url.substring("grayjay://video/".length);
|
||||
navigate(_fragVideoDetail, videoUrl);
|
||||
navigateWhenReady(_fragVideoDetail, videoUrl);
|
||||
} else if (url.startsWith("grayjay://channel/")) {
|
||||
val channelUrl = url.substring("grayjay://channel/".length);
|
||||
navigate(_fragMainChannel, channelUrl);
|
||||
navigateWhenReady(_fragMainChannel, channelUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -830,29 +862,29 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) on IO");
|
||||
if (StatePlatform.instance.hasEnabledVideoClient(url)) {
|
||||
if (StatePlatform.instance.hasEnabledContentClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found video client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (position > 0)
|
||||
navigate(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true));
|
||||
navigateWhenReady(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true));
|
||||
else
|
||||
navigate(_fragVideoDetail, url);
|
||||
navigateWhenReady(_fragVideoDetail, url);
|
||||
|
||||
_fragVideoDetail.maximizeVideoDetail(true);
|
||||
}
|
||||
return@withContext true;
|
||||
} else if (StatePlatform.instance.hasEnabledChannelClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found channel client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
navigate(_fragMainChannel, url);
|
||||
withContext(Dispatchers.Main) {
|
||||
navigateWhenReady(_fragMainChannel, url);
|
||||
delay(100);
|
||||
_fragVideoDetail.minimizeVideoDetail();
|
||||
};
|
||||
return@withContext true;
|
||||
} else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found playlist client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
navigate(_fragMainRemotePlaylist, url);
|
||||
withContext(Dispatchers.Main) {
|
||||
navigateWhenReady(_fragMainRemotePlaylist, url);
|
||||
delay(100);
|
||||
_fragVideoDetail.minimizeVideoDetail();
|
||||
};
|
||||
@@ -1064,6 +1096,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
Logger.v(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop")
|
||||
_fragVideoDetail.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig);
|
||||
Logger.v(TAG, "onPictureInPictureModeChanged Ready");
|
||||
|
||||
_pictureInPictureEnabled = isInPictureInPictureMode
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
@@ -1076,6 +1111,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
return fragCurrent is T;
|
||||
}
|
||||
|
||||
fun navigateWhenReady(segment: MainFragment, parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
|
||||
if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
navigate(segment, parameter, withHistory, isBack)
|
||||
} else {
|
||||
lifecycleScope.launch {
|
||||
lifecycle.withStateAtLeast(Lifecycle.State.RESUMED) {
|
||||
navigate(segment, parameter, withHistory, isBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate takes a MainFragment, and makes them the current main visible view
|
||||
* A parameter can be provided which becomes available in the onShow of said fragment
|
||||
@@ -1137,7 +1184,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory)
|
||||
fragBeforeOverlay = fragCurrent;
|
||||
|
||||
|
||||
fragCurrent = segment;
|
||||
_parameterCurrent = parameter;
|
||||
}
|
||||
@@ -1200,6 +1246,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
PlaylistFragment::class -> _fragMainPlaylist as T;
|
||||
RemotePlaylistFragment::class -> _fragMainRemotePlaylist as T;
|
||||
PostDetailFragment::class -> _fragPostDetail as T;
|
||||
ArticleDetailFragment::class -> _fragArticleDetail as T;
|
||||
WebDetailFragment::class -> _fragWebDetail as T;
|
||||
WatchLaterFragment::class -> _fragWatchlist as T;
|
||||
HistoryFragment::class -> _fragHistory as T;
|
||||
SourceDetailFragment::class -> _fragSourceDetail as T;
|
||||
|
||||
@@ -9,6 +9,8 @@ import android.widget.LinearLayout
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateSync
|
||||
@@ -29,6 +31,16 @@ class SyncHomeActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
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)
|
||||
setNavigationBarColorAndIcons()
|
||||
|
||||
@@ -54,7 +66,6 @@ class SyncHomeActivity : AppCompatActivity() {
|
||||
val view = _viewMap[publicKey]
|
||||
if (!session.isAuthorized) {
|
||||
if (view != null) {
|
||||
_layoutDevices.removeView(view)
|
||||
_viewMap.remove(publicKey)
|
||||
}
|
||||
return@launch
|
||||
@@ -89,6 +100,20 @@ class SyncHomeActivity : AppCompatActivity() {
|
||||
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() {
|
||||
@@ -100,11 +125,12 @@ class SyncHomeActivity : AppCompatActivity() {
|
||||
|
||||
private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView {
|
||||
val connected = session?.connected ?: false
|
||||
val authorized = session?.isAuthorized ?: false
|
||||
|
||||
syncDeviceView.setLinkType(session?.linkType ?: LinkType.None)
|
||||
.setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey)
|
||||
//TODO: also display public key?
|
||||
.setStatus(if (connected) "Connected" else "Disconnected")
|
||||
.setStatus(if (connected && authorized) "Connected" else "Disconnected or unauthorized")
|
||||
return syncDeviceView
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ class SyncPairActivity : AppCompatActivity() {
|
||||
|
||||
_layoutPairingSuccess.setOnClickListener {
|
||||
_layoutPairingSuccess.visibility = View.GONE
|
||||
finish()
|
||||
}
|
||||
_layoutPairingError.setOnClickListener {
|
||||
_layoutPairingError.visibility = View.GONE
|
||||
@@ -109,11 +110,17 @@ class SyncPairActivity : AppCompatActivity() {
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
StateSync.instance.connect(deviceInfo) { complete, message ->
|
||||
StateSync.instance.syncService?.connect(deviceInfo) { complete, message ->
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
if (complete != null && complete) {
|
||||
_layoutPairingSuccess.visibility = View.VISIBLE
|
||||
_layoutPairing.visibility = View.GONE
|
||||
if (complete != null) {
|
||||
if (complete) {
|
||||
_layoutPairingSuccess.visibility = View.VISIBLE
|
||||
_layoutPairing.visibility = View.GONE
|
||||
} else {
|
||||
_textError.text = message
|
||||
_layoutPairingError.visibility = View.VISIBLE
|
||||
_layoutPairing.visibility = View.GONE
|
||||
}
|
||||
} else {
|
||||
_textPairingStatus.text = message
|
||||
}
|
||||
@@ -137,8 +144,6 @@ class SyncPairActivity : AppCompatActivity() {
|
||||
_textError.text = e.message
|
||||
_layoutPairing.visibility = View.GONE
|
||||
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 selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT, StateSync.instance.pairingCode)
|
||||
val json = Json.encodeToString(selfDeviceInfo)
|
||||
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||
val url = "grayjay://sync/${base64}"
|
||||
setCode(url)
|
||||
val publicKey = StateSync.instance.syncService?.publicKey
|
||||
val pairingCode = StateSync.instance.syncService?.pairingCode
|
||||
if (publicKey == null || pairingCode == null) {
|
||||
setCode("Public key or pairing code was not known, is sync enabled?")
|
||||
} else {
|
||||
val selfDeviceInfo = SyncDeviceInfo(publicKey, ips.toTypedArray(), StateSync.PORT, pairingCode)
|
||||
val json = Json.encodeToString(selfDeviceInfo)
|
||||
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||
val url = "grayjay://sync/${base64}"
|
||||
setCode(url)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun setCode(code: String?) {
|
||||
|
||||
@@ -90,6 +90,7 @@ open class ManagedHttpClient {
|
||||
}
|
||||
|
||||
fun tryHead(url: String): Map<String, String>? {
|
||||
ensureNotMainThread()
|
||||
try {
|
||||
val result = head(url);
|
||||
if(result.isOk)
|
||||
@@ -104,7 +105,7 @@ open class ManagedHttpClient {
|
||||
}
|
||||
|
||||
fun socket(url: String, headers: MutableMap<String, String> = HashMap(), listener: SocketListener): Socket {
|
||||
|
||||
ensureNotMainThread()
|
||||
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
|
||||
.url(url);
|
||||
if(user_agent.isNotEmpty() && !headers.any { it.key.lowercase() == "user-agent" })
|
||||
@@ -300,6 +301,7 @@ open class ManagedHttpClient {
|
||||
}
|
||||
|
||||
fun send(msg: String) {
|
||||
ensureNotMainThread()
|
||||
socket.send(msg);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,14 +14,16 @@ class PlatformClientPool {
|
||||
private var _poolCounter = 0;
|
||||
private val _poolName: String?;
|
||||
private val _privatePool: Boolean;
|
||||
private val _isolatedInitialization: Boolean
|
||||
|
||||
var isDead: Boolean = false
|
||||
private set;
|
||||
val onDead = Event2<JSClient, PlatformClientPool>();
|
||||
|
||||
constructor(parentClient: IPlatformClient, name: String? = null, privatePool: Boolean = false) {
|
||||
constructor(parentClient: IPlatformClient, name: String? = null, privatePool: Boolean = false, isolatedInitialization: Boolean = false) {
|
||||
_poolName = name;
|
||||
_privatePool = privatePool;
|
||||
_isolatedInitialization = isolatedInitialization
|
||||
if(parentClient !is JSClient)
|
||||
throw IllegalArgumentException("Pooling only supported for JSClients right now");
|
||||
Logger.i(TAG, "Pool for ${parentClient.name} was started");
|
||||
@@ -53,7 +55,7 @@ class PlatformClientPool {
|
||||
reserved = _pool.keys.find { !it.isBusy };
|
||||
if(reserved == null && _pool.size < capacity) {
|
||||
Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})");
|
||||
reserved = _parent.getCopy(_privatePool);
|
||||
reserved = _parent.getCopy(_privatePool, _isolatedInitialization);
|
||||
|
||||
reserved?.onCaptchaException?.subscribe { client, ex ->
|
||||
StateApp.instance.handleCaptchaException(client, ex);
|
||||
|
||||
@@ -7,13 +7,15 @@ class PlatformMultiClientPool {
|
||||
|
||||
private var _isFake = false;
|
||||
private var _privatePool = false;
|
||||
private val _isolatedInitialization: Boolean
|
||||
|
||||
constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false) {
|
||||
constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false, isolatedInitialization: Boolean = false) {
|
||||
_name = name;
|
||||
_maxCap = if(maxCap > 0)
|
||||
maxCap
|
||||
else 99;
|
||||
_privatePool = isPrivatePool;
|
||||
_isolatedInitialization = isolatedInitialization
|
||||
}
|
||||
|
||||
fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient {
|
||||
@@ -21,7 +23,7 @@ class PlatformMultiClientPool {
|
||||
return parentClient;
|
||||
val pool = synchronized(_clientPools) {
|
||||
if(!_clientPools.containsKey(parentClient))
|
||||
_clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool).apply {
|
||||
_clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool, _isolatedInitialization).apply {
|
||||
this.onDead.subscribe { _, pool ->
|
||||
synchronized(_clientPools) {
|
||||
if(_clientPools[parentClient] == pool)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.futo.platformplayer.api.media.models.article
|
||||
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
|
||||
interface IPlatformArticle: IPlatformContent {
|
||||
val summary: String?;
|
||||
val thumbnails: Thumbnails?;
|
||||
}
|
||||
+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),
|
||||
ARTICLE(3),
|
||||
PLAYLIST(4),
|
||||
WEB(7),
|
||||
|
||||
URL(9),
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException
|
||||
enum class TextType(val value: Int) {
|
||||
RAW(0),
|
||||
HTML(1),
|
||||
MARKUP(2);
|
||||
MARKUP(2),
|
||||
CODE(3);
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int): TextType
|
||||
|
||||
@@ -54,8 +54,11 @@ class DevJSClient : JSClient {
|
||||
return DevJSClient(context, config, _devScript, _auth, _captcha, devID, descriptor.settings);
|
||||
}
|
||||
|
||||
override fun getCopy(privateCopy: Boolean): JSClient {
|
||||
return DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, saveState(), devID);
|
||||
override fun getCopy(privateCopy: Boolean, noSaveState: Boolean): JSClient {
|
||||
val client = DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, if (noSaveState) null else saveState(), devID);
|
||||
if (noSaveState)
|
||||
client.initialize()
|
||||
return client
|
||||
}
|
||||
|
||||
override fun initialize() {
|
||||
|
||||
@@ -195,8 +195,11 @@ open class JSClient : IPlatformClient {
|
||||
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
|
||||
}
|
||||
|
||||
open fun getCopy(withoutCredentials: Boolean = false): JSClient {
|
||||
return JSClient(_context, descriptor, saveState(), _script, withoutCredentials);
|
||||
open fun getCopy(withoutCredentials: Boolean = false, noSaveState: Boolean = false): JSClient {
|
||||
val client = JSClient(_context, descriptor, if (noSaveState) null else saveState(), _script, withoutCredentials);
|
||||
if (noSaveState)
|
||||
client.initialize()
|
||||
return client
|
||||
}
|
||||
|
||||
fun getUnderlyingPlugin(): V8Plugin {
|
||||
@@ -211,6 +214,8 @@ open class JSClient : IPlatformClient {
|
||||
}
|
||||
|
||||
override fun initialize() {
|
||||
if (_initialized) return
|
||||
|
||||
Logger.i(TAG, "Plugin [${config.name}] initializing");
|
||||
plugin.start();
|
||||
plugin.execute("plugin.config = ${Json.encodeToString(config)}");
|
||||
|
||||
+7
-1
@@ -127,7 +127,7 @@ class JSHttpClient : ManagedHttpClient {
|
||||
}
|
||||
|
||||
if(doApplyCookies) {
|
||||
if (_currentCookieMap.isNotEmpty()) {
|
||||
if (_currentCookieMap.isNotEmpty() || _otherCookieMap.isNotEmpty()) {
|
||||
val cookiesToApply = hashMapOf<String, String>();
|
||||
synchronized(_currentCookieMap) {
|
||||
for(cookie in _currentCookieMap
|
||||
@@ -135,6 +135,12 @@ class JSHttpClient : ManagedHttpClient {
|
||||
.flatMap { it.value.toList() })
|
||||
cookiesToApply[cookie.first] = cookie.second;
|
||||
};
|
||||
synchronized(_otherCookieMap) {
|
||||
for(cookie in _otherCookieMap
|
||||
.filter { domain.matchesDomain(it.key) }
|
||||
.flatMap { it.value.toList() })
|
||||
cookiesToApply[cookie.first] = cookie.second;
|
||||
}
|
||||
|
||||
if(cookiesToApply.size > 0) {
|
||||
val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; ");
|
||||
|
||||
+3
-1
@@ -27,7 +27,9 @@ interface IJSContent: IPlatformContent {
|
||||
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
|
||||
ContentType.PLAYLIST -> JSPlaylist(config, obj);
|
||||
ContentType.LOCKED -> JSLockedContent(config, obj);
|
||||
ContentType.CHANNEL -> JSChannelContent(config, obj)
|
||||
ContentType.CHANNEL -> JSChannelContent(config, obj);
|
||||
ContentType.ARTICLE -> JSArticle(config, obj);
|
||||
ContentType.WEB -> JSWeb(config, obj);
|
||||
else -> throw NotImplementedError("Unknown content type ${type}");
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -17,6 +17,7 @@ interface IJSContentDetails: IPlatformContent {
|
||||
ContentType.MEDIA -> JSVideoDetails(plugin, obj);
|
||||
ContentType.POST -> JSPostDetails(plugin.config, obj);
|
||||
ContentType.ARTICLE -> JSArticleDetails(plugin, obj);
|
||||
ContentType.WEB -> JSWebDetails(plugin, obj);
|
||||
else -> throw NotImplementedError("Unknown content type ${type}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.api.media.models.post.TextType
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullableList
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
open class JSArticle : JSContent, IPlatformArticle, IPluginSourced {
|
||||
final override val contentType: ContentType get() = ContentType.ARTICLE;
|
||||
|
||||
override val summary: String;
|
||||
override val thumbnails: Thumbnails?;
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
|
||||
val contextName = "PlatformArticle";
|
||||
|
||||
summary = _content.getOrDefault(config, "summary", contextName, "") ?: "";
|
||||
thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName));
|
||||
|
||||
}
|
||||
}
|
||||
+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.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
|
||||
import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
@@ -21,20 +23,20 @@ import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullableList
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails {
|
||||
open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
|
||||
final override val contentType: ContentType get() = ContentType.ARTICLE;
|
||||
|
||||
private val _hasGetComments: Boolean;
|
||||
private val _hasGetContentRecommendations: Boolean;
|
||||
|
||||
val rating: IRating;
|
||||
override val rating: IRating;
|
||||
|
||||
val summary: String;
|
||||
val thumbnails: Thumbnails?;
|
||||
val segments: List<IJSArticleSegment>;
|
||||
override val summary: String;
|
||||
override val thumbnails: Thumbnails?;
|
||||
override val segments: List<IJSArticleSegment>;
|
||||
|
||||
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
|
||||
val contextName = "PlatformPost";
|
||||
val contextName = "PlatformArticle";
|
||||
|
||||
rating = obj.getOrDefault<V8ValueObject>(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0);
|
||||
summary = _content.getOrThrow(client.config, "summary", contextName);
|
||||
@@ -99,6 +101,7 @@ open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails
|
||||
return when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
|
||||
SegmentType.TEXT -> JSTextSegment(client, obj);
|
||||
SegmentType.IMAGES -> JSImagesSegment(client, obj);
|
||||
SegmentType.HEADER -> JSHeaderSegment(client, obj);
|
||||
SegmentType.NESTED -> JSNestedSegment(client, obj);
|
||||
else -> null;
|
||||
}
|
||||
@@ -110,6 +113,7 @@ enum class SegmentType(val value: Int) {
|
||||
UNKNOWN(0),
|
||||
TEXT(1),
|
||||
IMAGES(2),
|
||||
HEADER(3),
|
||||
|
||||
NESTED(9);
|
||||
|
||||
@@ -150,6 +154,17 @@ class JSImagesSegment: IJSArticleSegment {
|
||||
caption = obj.getOrDefault(client.config, "caption", contextName, "") ?: "";
|
||||
}
|
||||
}
|
||||
class JSHeaderSegment: IJSArticleSegment {
|
||||
override val type = SegmentType.HEADER;
|
||||
val content: String;
|
||||
val level: Int;
|
||||
|
||||
constructor(client: JSClient, obj: V8ValueObject) {
|
||||
val contextName = "JSHeaderSegment";
|
||||
content = obj.getOrDefault(client.config, "content", contextName, "") ?: "";
|
||||
level = obj.getOrDefault(client.config, "level", contextName, 1) ?: 1;
|
||||
}
|
||||
}
|
||||
class JSNestedSegment: IJSArticleSegment {
|
||||
override val type = SegmentType.NESTED;
|
||||
val nested: IPlatformContent;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.api.media.models.post.TextType
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullableList
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
open class JSWeb : JSContent, IPluginSourced {
|
||||
final override val contentType: ContentType get() = ContentType.WEB;
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
|
||||
val contextName = "PlatformWeb";
|
||||
}
|
||||
}
|
||||
+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;
|
||||
} catch (e: Throwable) {
|
||||
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
|
||||
get() {
|
||||
val diff = (System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0;
|
||||
val diff = if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
||||
return time + diff;
|
||||
};
|
||||
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
|
||||
|
||||
@@ -10,7 +10,9 @@ import com.futo.platformplayer.toHexString
|
||||
import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
@@ -56,6 +58,10 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
private var _mediaSessionId: Int? = null;
|
||||
private var _thread: Thread? = null;
|
||||
private var _pingThread: Thread? = null;
|
||||
private var _launchRetries = 0
|
||||
private val MAX_LAUNCH_RETRIES = 3
|
||||
private var _lastLaunchTime_ms = 0L
|
||||
private var _retryJob: Job? = null
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
this.name = name;
|
||||
@@ -229,6 +235,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
launchObject.put("appId", "CC1AD845");
|
||||
launchObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
||||
_lastLaunchTime_ms = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
private fun getStatus() {
|
||||
@@ -268,6 +275,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
_contentType = null;
|
||||
_streamType = null;
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
_transportId = null;
|
||||
}
|
||||
|
||||
@@ -282,6 +290,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
|
||||
_started = true;
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
_mediaSessionId = null;
|
||||
|
||||
Logger.i(TAG, "Starting...");
|
||||
@@ -322,6 +331,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
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
|
||||
while (_scopeIO?.isActive == true) {
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
_mediaSessionId = null;
|
||||
|
||||
Logger.i(TAG, "Connecting to Chromecast.");
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
@@ -392,7 +406,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
try {
|
||||
val inputStream = _inputStream ?: break;
|
||||
|
||||
synchronized(_inputStreamLock)
|
||||
val message = synchronized(_inputStreamLock)
|
||||
{
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
val b1 = inputStream.readUnsignedByte();
|
||||
@@ -404,7 +418,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
||||
inputStream.skip(size.toLong());
|
||||
return@synchronized
|
||||
return@synchronized null
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||
@@ -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?
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
val message = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
Logger.i(TAG, "Received message: $message");
|
||||
val msg = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||
if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
Logger.i(TAG, "Received message: $msg");
|
||||
}
|
||||
return@synchronized msg
|
||||
}
|
||||
|
||||
if (message != null) {
|
||||
try {
|
||||
handleMessage(message);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to handle message.", e);
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e: java.net.SocketException) {
|
||||
@@ -485,6 +503,10 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e);
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,6 +533,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
if (_sessionId == null) {
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
_sessionId = applicationUpdate.getString("sessionId");
|
||||
_launchRetries = 0
|
||||
|
||||
val transportId = applicationUpdate.getString("transportId");
|
||||
connectMediaChannel(transportId);
|
||||
@@ -525,21 +548,40 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
}
|
||||
|
||||
if (!sessionIsRunning) {
|
||||
_sessionId = null;
|
||||
_mediaSessionId = null;
|
||||
setTime(0.0);
|
||||
_transportId = null;
|
||||
Logger.w(TAG, "Session not found.");
|
||||
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
|
||||
_sessionId = null
|
||||
_mediaSessionId = null
|
||||
setTime(0.0)
|
||||
_transportId = null
|
||||
|
||||
if (_launching) {
|
||||
Logger.i(TAG, "Player not found, launching.");
|
||||
launchPlayer();
|
||||
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
} else if (!_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||
// Maybe the first GET_STATUS came back empty; still try launching
|
||||
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
|
||||
_launching = true
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
} else {
|
||||
Logger.e(TAG, "Player not found after $_launchRetries attempts; giving up.")
|
||||
Logger.i(TAG, "Unable to start media receiver on device")
|
||||
stop()
|
||||
}
|
||||
} else {
|
||||
Logger.i(TAG, "Player not found, disconnecting.");
|
||||
stop();
|
||||
if (_retryJob == null) {
|
||||
Logger.i(TAG, "Scheduled retry job over 5 seconds")
|
||||
_retryJob = _scopeIO?.launch(Dispatchers.IO) {
|
||||
delay(5000)
|
||||
getStatus()
|
||||
_retryJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_launching = false;
|
||||
_launching = false
|
||||
_launchRetries = 0
|
||||
}
|
||||
|
||||
val volume = status.getJSONObject("volume");
|
||||
@@ -566,7 +608,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
}
|
||||
|
||||
isPlaying = playerState == "PLAYING";
|
||||
if (isPlaying) {
|
||||
if (isPlaying || playerState == "PAUSED") {
|
||||
setTime(currentTime);
|
||||
}
|
||||
|
||||
@@ -581,6 +623,8 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
if (message.sourceId == "receiver-0") {
|
||||
Logger.i(TAG, "Close received.");
|
||||
stop();
|
||||
} else if (_transportId == message.sourceId) {
|
||||
throw Exception("Transport id closed.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -615,6 +659,9 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
localAddress = null;
|
||||
_started = false;
|
||||
|
||||
_retryJob?.cancel()
|
||||
_retryJob = null
|
||||
|
||||
val socket = _socket;
|
||||
val scopeIO = _scopeIO;
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
@@ -289,6 +290,7 @@ class FCastCastingDevice : CastingDevice {
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
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.util.Base64
|
||||
import android.util.Log
|
||||
import java.net.NetworkInterface
|
||||
import java.net.Inet4Address
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.R
|
||||
@@ -41,12 +43,11 @@ import com.futo.platformplayer.builders.DashBuilder
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||
import com.futo.platformplayer.findPreferredAddress
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.parsers.HLS
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateSync
|
||||
import com.futo.platformplayer.states.StateSync.Companion
|
||||
import com.futo.platformplayer.stores.CastingDeviceInfoStorage
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.toUrlAddress
|
||||
@@ -57,9 +58,11 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import java.util.Collections
|
||||
import java.util.UUID
|
||||
|
||||
class StateCasting {
|
||||
@@ -485,7 +488,7 @@ class StateCasting {
|
||||
}
|
||||
} else {
|
||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests;
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
if (videoSource is IVideoUrlSource) {
|
||||
@@ -580,7 +583,7 @@ class StateCasting {
|
||||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
val videoPath = "/video-${id}"
|
||||
val videoUrl = url + videoPath;
|
||||
@@ -599,7 +602,7 @@ class StateCasting {
|
||||
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
val audioPath = "/audio-${id}"
|
||||
val audioUrl = url + audioPath;
|
||||
@@ -618,7 +621,7 @@ class StateCasting {
|
||||
private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List<String> {
|
||||
val ad = activeDevice ?: return listOf()
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"
|
||||
val url = getLocalUrl(ad)
|
||||
val id = UUID.randomUUID()
|
||||
|
||||
val hlsPath = "/hls-${id}"
|
||||
@@ -714,7 +717,7 @@ class StateCasting {
|
||||
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
@@ -764,7 +767,7 @@ class StateCasting {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val videoPath = "/video-${id}"
|
||||
@@ -829,7 +832,7 @@ class StateCasting {
|
||||
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
||||
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
|
||||
val id = UUID.randomUUID();
|
||||
val hlsPath = "/hls-${id}"
|
||||
@@ -999,7 +1002,7 @@ class StateCasting {
|
||||
|
||||
private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val hlsPath = "/hls-${id}"
|
||||
@@ -1129,7 +1132,7 @@ class StateCasting {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
@@ -1215,6 +1218,15 @@ class StateCasting {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLocalUrl(ad: CastingDevice): String {
|
||||
var address = ad.localAddress!!
|
||||
if (address.isLinkLocalAddress) {
|
||||
address = findPreferredAddress() ?: address
|
||||
Logger.i(TAG, "Selected casting address: $address")
|
||||
}
|
||||
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
@@ -1222,7 +1234,7 @@ class StateCasting {
|
||||
cleanExecutors()
|
||||
_castServer.removeAllHandlers("castDashRaw")
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
|
||||
@@ -82,7 +82,11 @@ class TaskHandler<TParameter, TResult> {
|
||||
handled = true;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Handled exception in TaskHandler onSuccess.", e);
|
||||
onError.emit(e, parameter);
|
||||
try {
|
||||
onError.emit(e, parameter);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception in .exception handler 1", e)
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
@@ -99,10 +103,14 @@ class TaskHandler<TParameter, TResult> {
|
||||
if (id != _idGenerator)
|
||||
return@withContext;
|
||||
|
||||
if (!onError.emit(e, parameter)) {
|
||||
Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e);
|
||||
} else {
|
||||
//Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); (Prevents duplicate logs)
|
||||
try {
|
||||
if (!onError.emit(e, parameter)) {
|
||||
Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e);
|
||||
} else {
|
||||
//Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); (Prevents duplicate logs)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception in .exception handler 2", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.toHumanBitrate
|
||||
import com.futo.platformplayer.toHumanBytesSpeed
|
||||
import com.futo.polycentric.core.hexStringToByteArray
|
||||
import hasAnySource
|
||||
import isDownloadable
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -59,16 +60,21 @@ import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.Transient
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.lang.Thread.sleep
|
||||
import java.nio.ByteBuffer
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ForkJoinPool
|
||||
import java.util.concurrent.ForkJoinTask
|
||||
import java.util.concurrent.ThreadLocalRandom
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.time.times
|
||||
|
||||
@@ -564,6 +570,14 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
private fun decryptSegment(encryptedSegment: ByteArray, key: ByteArray, iv: ByteArray): ByteArray {
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
val secretKey = SecretKeySpec(key, "AES")
|
||||
val ivSpec = IvParameterSpec(iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec)
|
||||
return cipher.doFinal(encryptedSegment)
|
||||
}
|
||||
|
||||
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
@@ -579,6 +593,14 @@ class VideoDownload {
|
||||
?: throw Exception("Variant playlist content is empty")
|
||||
|
||||
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
|
||||
val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) {
|
||||
val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl)
|
||||
check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" }
|
||||
DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv?.hexStringToByteArray())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||
if (segment !is HLS.MediaSegment) {
|
||||
return@forEachIndexed
|
||||
@@ -590,7 +612,7 @@ class VideoDownload {
|
||||
try {
|
||||
segmentFiles.add(segmentFile)
|
||||
|
||||
val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri) { segmentLength, totalRead, lastSpeed ->
|
||||
val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo, index) { segmentLength, totalRead, lastSpeed ->
|
||||
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
|
||||
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
|
||||
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
|
||||
@@ -630,10 +652,8 @@ class VideoDownload {
|
||||
|
||||
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
|
||||
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
|
||||
|
||||
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
|
||||
val cmd =
|
||||
"-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
|
||||
|
||||
val statisticsCallback = StatisticsCallback { _ ->
|
||||
//TODO: Show progress?
|
||||
@@ -643,7 +663,6 @@ class VideoDownload {
|
||||
val session = FFmpegKit.executeAsync(cmd,
|
||||
{ session ->
|
||||
if (ReturnCode.isSuccess(session.returnCode)) {
|
||||
fileList.delete()
|
||||
continuation.resumeWith(Result.success(Unit))
|
||||
} else {
|
||||
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
||||
@@ -651,7 +670,6 @@ class VideoDownload {
|
||||
} else {
|
||||
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
||||
}
|
||||
fileList.delete()
|
||||
continuation.resumeWithException(RuntimeException(errorMessage))
|
||||
}
|
||||
},
|
||||
@@ -771,7 +789,7 @@ class VideoDownload {
|
||||
else {
|
||||
Logger.i(TAG, "Download $name Sequential");
|
||||
try {
|
||||
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, onProgress);
|
||||
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, 0, onProgress);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
|
||||
throw e
|
||||
@@ -798,7 +816,31 @@ class VideoDownload {
|
||||
}
|
||||
return sourceLength!!;
|
||||
}
|
||||
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
|
||||
data class DecryptionInfo(
|
||||
val key: ByteArray,
|
||||
val iv: ByteArray?
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as DecryptionInfo
|
||||
|
||||
if (!key.contentEquals(other.key)) return false
|
||||
if (!iv.contentEquals(other.iv)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = key.contentHashCode()
|
||||
result = 31 * result + iv.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
val progressRate: Int = 4096 * 5;
|
||||
var lastProgressCount: Int = 0;
|
||||
val speedRate: Int = 4096 * 5;
|
||||
@@ -818,6 +860,8 @@ class VideoDownload {
|
||||
val sourceLength = result.body.contentLength();
|
||||
val sourceStream = result.body.byteStream();
|
||||
|
||||
val segmentBuffer = ByteArrayOutputStream()
|
||||
|
||||
var totalRead: Long = 0;
|
||||
try {
|
||||
var read: Int;
|
||||
@@ -828,7 +872,7 @@ class VideoDownload {
|
||||
if (read < 0)
|
||||
break;
|
||||
|
||||
fileStream.write(buffer, 0, read);
|
||||
segmentBuffer.write(buffer, 0, read);
|
||||
|
||||
totalRead += read;
|
||||
|
||||
@@ -854,6 +898,21 @@ class VideoDownload {
|
||||
result.body.close()
|
||||
}
|
||||
|
||||
if (decryptionInfo != null) {
|
||||
var iv = decryptionInfo.iv
|
||||
if (iv == null) {
|
||||
iv = ByteBuffer.allocate(16)
|
||||
.putLong(0L)
|
||||
.putLong(index.toLong())
|
||||
.array()
|
||||
}
|
||||
|
||||
val decryptedData = decryptSegment(segmentBuffer.toByteArray(), decryptionInfo.key, iv!!)
|
||||
fileStream.write(decryptedData)
|
||||
} else {
|
||||
fileStream.write(segmentBuffer.toByteArray())
|
||||
}
|
||||
|
||||
onProgress(sourceLength, totalRead, 0);
|
||||
return sourceLength;
|
||||
}
|
||||
@@ -1160,6 +1219,8 @@ class VideoDownload {
|
||||
fun audioContainerToExtension(container: String): String {
|
||||
if (container.contains("audio/mp4"))
|
||||
return "mp4a";
|
||||
else if (container.contains("video/mp4"))
|
||||
return "mp4";
|
||||
else if (container.contains("audio/mpeg"))
|
||||
return "mpga";
|
||||
else if (container.contains("audio/mp3"))
|
||||
@@ -1167,7 +1228,7 @@ class VideoDownload {
|
||||
else if (container.contains("audio/webm"))
|
||||
return "webm";
|
||||
else if (container == "application/vnd.apple.mpegurl")
|
||||
return "mp4a";
|
||||
return "m4a";
|
||||
else
|
||||
return "audio";// throw IllegalStateException("Unknown container: " + container)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class VideoExport {
|
||||
outputFile = f;
|
||||
} else if (v != null) {
|
||||
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container);
|
||||
val f = downloadRoot.createFile(v.container, outputFileName)
|
||||
val f = downloadRoot.createFile(if (v.container == "application/vnd.apple.mpegurl") "video/mp4" else v.container, outputFileName)
|
||||
?: throw Exception("Failed to create file in external directory.");
|
||||
|
||||
Logger.i(TAG, "Copying video.");
|
||||
@@ -81,8 +81,8 @@ class VideoExport {
|
||||
outputFile = f;
|
||||
} else if (a != null) {
|
||||
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container);
|
||||
val f = downloadRoot.createFile(a.container, outputFileName)
|
||||
?: throw Exception("Failed to create file in external directory.");
|
||||
val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "video/mp4" else a.container, outputFileName)
|
||||
?: throw Exception("Failed to create file in external directory.");
|
||||
|
||||
Logger.i(TAG, "Copying audio.");
|
||||
|
||||
|
||||
@@ -188,6 +188,14 @@ class V8Plugin {
|
||||
whenNotBusy {
|
||||
synchronized(_runtimeLock) {
|
||||
isStopped = true;
|
||||
|
||||
//Cleanup http
|
||||
for(pack in _depsPackages) {
|
||||
if(pack is PackageHttp) {
|
||||
pack.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
_runtime?.let {
|
||||
_runtime = null;
|
||||
if(!it.isClosed && !it.isDead) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClientConstants
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
@@ -77,6 +78,22 @@ class PackageBridge : V8Package {
|
||||
return "android";
|
||||
}
|
||||
|
||||
@V8Property
|
||||
fun supportedContent(): Array<Int> {
|
||||
return arrayOf(
|
||||
ContentType.MEDIA.value,
|
||||
ContentType.POST.value,
|
||||
ContentType.PLAYLIST.value,
|
||||
ContentType.WEB.value,
|
||||
ContentType.URL.value,
|
||||
ContentType.NESTED_VIDEO.value,
|
||||
ContentType.CHANNEL.value,
|
||||
ContentType.LOCKED.value,
|
||||
ContentType.PLACEHOLDER.value,
|
||||
ContentType.DEFERRED.value
|
||||
)
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun dispose(value: V8Value) {
|
||||
Logger.e(TAG, "Manual dispose: " + value.javaClass.name);
|
||||
|
||||
@@ -8,9 +8,7 @@ import com.caoccao.javet.enums.V8ProxyMode
|
||||
import com.caoccao.javet.interop.V8Runtime
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.caoccao.javet.values.reference.V8ValueArrayBuffer
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.caoccao.javet.values.reference.V8ValueSharedArrayBuffer
|
||||
import com.caoccao.javet.values.reference.V8ValueTypedArray
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
@@ -20,15 +18,9 @@ import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.engine.internal.IV8Convertable
|
||||
import com.futo.platformplayer.engine.internal.V8BindObject
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.SocketTimeoutException
|
||||
import java.util.concurrent.ForkJoinPool
|
||||
import java.util.concurrent.ForkJoinTask
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.streams.asSequence
|
||||
|
||||
class PackageHttp: V8Package {
|
||||
@Transient
|
||||
@@ -49,6 +41,9 @@ class PackageHttp: V8Package {
|
||||
private var _batchPoolLock: Any = Any();
|
||||
private var _batchPool: ForkJoinPool? = null;
|
||||
|
||||
private val aliveSockets = mutableListOf<SocketResult>();
|
||||
private var _cleanedUp = false;
|
||||
|
||||
|
||||
constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) {
|
||||
_config = config;
|
||||
@@ -58,6 +53,27 @@ class PackageHttp: V8Package {
|
||||
_packageClientAuth = PackageHttpClient(this, _clientAuth);
|
||||
}
|
||||
|
||||
fun cleanup(){
|
||||
Logger.w(TAG, "PackageHttp Cleaning up")
|
||||
val sockets = synchronized(aliveSockets) { aliveSockets.toList() }
|
||||
_cleanedUp = true;
|
||||
for(socket in sockets){
|
||||
try {
|
||||
Logger.w(TAG, "PackageHttp Socket Cleaned Up");
|
||||
socket.close(1001, "Cleanup");
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to close socket", ex);
|
||||
}
|
||||
}
|
||||
if(sockets.size > 0) {
|
||||
//Thread.sleep(100); //Give sockets a bit
|
||||
}
|
||||
synchronized(aliveSockets) {
|
||||
aliveSockets.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
Automatically adjusting threadpool dedicated per PackageHttp for batch requests.
|
||||
@@ -111,24 +127,24 @@ class PackageHttp: V8Package {
|
||||
@V8Function
|
||||
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
|
||||
return if(useAuth)
|
||||
_packageClientAuth.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
|
||||
_packageClientAuth.requestInternal(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
|
||||
else
|
||||
_packageClient.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
|
||||
_packageClient.requestInternal(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
|
||||
return if(useAuth)
|
||||
_packageClientAuth.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
|
||||
_packageClientAuth.requestWithBodyInternal(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
|
||||
else
|
||||
_packageClient.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
|
||||
_packageClient.requestWithBodyInternal(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
|
||||
}
|
||||
@V8Function
|
||||
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse {
|
||||
return if(useAuth)
|
||||
_packageClientAuth.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING)
|
||||
_packageClientAuth.GETInternal(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING)
|
||||
else
|
||||
_packageClient.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
|
||||
_packageClient.GETInternal(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
|
||||
}
|
||||
@V8Function
|
||||
fun POST(url: String, body: Any, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse {
|
||||
@@ -136,15 +152,15 @@ class PackageHttp: V8Package {
|
||||
val client = if(useAuth) _packageClientAuth else _packageClient;
|
||||
|
||||
if(body is V8ValueString)
|
||||
return client.POST(url, body.value, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
|
||||
return client.POSTInternal(url, body.value, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
|
||||
else if(body is String)
|
||||
return client.POST(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
|
||||
return client.POSTInternal(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
|
||||
else if(body is V8ValueTypedArray)
|
||||
return client.POST(url, body.toBytes(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
|
||||
return client.POSTInternal(url, body.toBytes(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
|
||||
else if(body is ByteArray)
|
||||
return client.POST(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
|
||||
return client.POSTInternal(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
|
||||
else if(body is ArrayList<*>) //Avoid this case, used purely for testing
|
||||
return client.POST(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
|
||||
return client.POSTInternal(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
|
||||
else
|
||||
throw NotImplementedError("Body type " + body?.javaClass?.name?.toString() + " not implemented for POST");
|
||||
}
|
||||
@@ -276,9 +292,9 @@ class PackageHttp: V8Package {
|
||||
if(it.second.method == "DUMMY")
|
||||
return@autoParallelPool null;
|
||||
if(it.second.body != null)
|
||||
return@autoParallelPool it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType);
|
||||
return@autoParallelPool it.first.requestWithBodyInternal(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType);
|
||||
else
|
||||
return@autoParallelPool it.first.request(it.second.method, it.second.url, it.second.headers, it.second.respType);
|
||||
return@autoParallelPool it.first.requestInternal(it.second.method, it.second.url, it.second.headers, it.second.respType);
|
||||
}.map {
|
||||
if(it.second != null)
|
||||
throw it.second!!;
|
||||
@@ -345,7 +361,9 @@ class PackageHttp: V8Package {
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
|
||||
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
|
||||
= requestInternal(method, url, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING);
|
||||
fun requestInternal(method: String, url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
|
||||
applyDefaultHeaders(headers);
|
||||
return logExceptions {
|
||||
return@logExceptions catchHttp {
|
||||
@@ -364,7 +382,9 @@ class PackageHttp: V8Package {
|
||||
};
|
||||
}
|
||||
@V8Function
|
||||
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
|
||||
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
|
||||
= requestWithBodyInternal(method, url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
|
||||
fun requestWithBodyInternal(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
|
||||
applyDefaultHeaders(headers);
|
||||
return logExceptions {
|
||||
catchHttp {
|
||||
@@ -385,7 +405,9 @@ class PackageHttp: V8Package {
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
|
||||
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
|
||||
= GETInternal(url, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
|
||||
fun GETInternal(url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
|
||||
applyDefaultHeaders(headers);
|
||||
return logExceptions {
|
||||
catchHttp {
|
||||
@@ -407,7 +429,9 @@ class PackageHttp: V8Package {
|
||||
};
|
||||
}
|
||||
@V8Function
|
||||
fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
|
||||
fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
|
||||
= POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
|
||||
fun POSTInternal(url: String, body: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
|
||||
applyDefaultHeaders(headers);
|
||||
return logExceptions {
|
||||
catchHttp {
|
||||
@@ -429,7 +453,9 @@ class PackageHttp: V8Package {
|
||||
};
|
||||
}
|
||||
@V8Function
|
||||
fun POST(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
|
||||
fun POST(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
|
||||
= POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
|
||||
fun POSTInternal(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
|
||||
applyDefaultHeaders(headers);
|
||||
return logExceptions {
|
||||
catchHttp {
|
||||
@@ -453,9 +479,16 @@ class PackageHttp: V8Package {
|
||||
|
||||
@V8Function
|
||||
fun socket(url: String, headers: Map<String, String>? = null): SocketResult {
|
||||
if(_package._cleanedUp)
|
||||
throw IllegalStateException("Plugin shutdown");
|
||||
val socketHeaders = headers?.toMutableMap() ?: HashMap();
|
||||
applyDefaultHeaders(socketHeaders);
|
||||
return SocketResult(this, _client, url, socketHeaders);
|
||||
val socket = SocketResult(_package, this, _client, url, socketHeaders);
|
||||
Logger.w(TAG, "PackageHttp Socket opened");
|
||||
synchronized(_package.aliveSockets) {
|
||||
_package.aliveSockets.add(socket);
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
private fun applyDefaultHeaders(headerMap: MutableMap<String, String>) {
|
||||
@@ -561,13 +594,15 @@ class PackageHttp: V8Package {
|
||||
|
||||
private var _listeners: V8ValueObject? = null;
|
||||
|
||||
private val _package: PackageHttp;
|
||||
private val _packageClient: PackageHttpClient;
|
||||
private val _client: ManagedHttpClient;
|
||||
private val _url: String;
|
||||
private val _headers: Map<String, String>;
|
||||
|
||||
constructor(pack: PackageHttpClient, client: ManagedHttpClient, url: String, headers: Map<String,String>) {
|
||||
constructor(parent: PackageHttp, pack: PackageHttpClient, client: ManagedHttpClient, url: String, headers: Map<String,String>) {
|
||||
_packageClient = pack;
|
||||
_package = parent;
|
||||
_client = client;
|
||||
_url = url;
|
||||
_headers = headers;
|
||||
@@ -593,7 +628,7 @@ class PackageHttp: V8Package {
|
||||
override fun open() {
|
||||
Logger.i(TAG, "Websocket opened: " + _url);
|
||||
_isOpen = true;
|
||||
if(hasOpen) {
|
||||
if(hasOpen && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_listeners?.invokeVoid("open", arrayOf<Any>());
|
||||
}
|
||||
@@ -603,7 +638,7 @@ class PackageHttp: V8Package {
|
||||
}
|
||||
}
|
||||
override fun message(msg: String) {
|
||||
if(hasMessage) {
|
||||
if(hasMessage && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_listeners?.invokeVoid("message", msg);
|
||||
}
|
||||
@@ -611,7 +646,7 @@ class PackageHttp: V8Package {
|
||||
}
|
||||
}
|
||||
override fun closing(code: Int, reason: String) {
|
||||
if(hasClosing)
|
||||
if(hasClosing && _listeners?.isClosed != true)
|
||||
{
|
||||
try {
|
||||
_listeners?.invokeVoid("closing", code, reason);
|
||||
@@ -623,7 +658,7 @@ class PackageHttp: V8Package {
|
||||
}
|
||||
override fun closed(code: Int, reason: String) {
|
||||
_isOpen = false;
|
||||
if(hasClosed) {
|
||||
if(hasClosed && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_listeners?.invokeVoid("closed", code, reason);
|
||||
}
|
||||
@@ -631,11 +666,15 @@ class PackageHttp: V8Package {
|
||||
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
|
||||
}
|
||||
}
|
||||
Logger.w(TAG, "PackageHttp Socket removed");
|
||||
synchronized(_package.aliveSockets) {
|
||||
_package.aliveSockets.remove(this@SocketResult);
|
||||
}
|
||||
}
|
||||
override fun failure(exception: Throwable) {
|
||||
_isOpen = false;
|
||||
Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception);
|
||||
if(hasFailure) {
|
||||
if(hasFailure && _listeners?.isClosed != true) {
|
||||
try {
|
||||
_listeners?.invokeVoid("failure", exception.message);
|
||||
}
|
||||
|
||||
+66
-10
@@ -5,6 +5,7 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
@@ -25,6 +26,7 @@ import com.futo.platformplayer.api.media.structures.MultiPager
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.exceptions.ChannelException
|
||||
@@ -32,9 +34,11 @@ import com.futo.platformplayer.fragment.mainactivity.main.FeedView
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateCache
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.SearchView
|
||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
|
||||
@@ -54,6 +58,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
||||
private var _results: ArrayList<IPlatformContent> = arrayListOf();
|
||||
private var _adapterResults: InsertedViewAdapterWithLoader<ContentPreviewViewHolder>? = null;
|
||||
private var _lastPolycentricProfile: PolycentricProfile? = null;
|
||||
private var _query: String? = null
|
||||
private var _searchView: SearchView? = null
|
||||
|
||||
val onContentClicked = Event2<IPlatformContent, Long>();
|
||||
val onContentUrlClicked = Event2<String, ContentType>();
|
||||
@@ -68,16 +74,32 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
||||
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
|
||||
Logger.i(TAG, "getContentPager");
|
||||
|
||||
val lastPolycentricProfile = _lastPolycentricProfile;
|
||||
var pager: IPager<IPlatformContent>? = null;
|
||||
if (lastPolycentricProfile != null)
|
||||
pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile);
|
||||
var pager: IPager<IPlatformContent>? = null
|
||||
val query = _query
|
||||
if (!query.isNullOrBlank()) {
|
||||
if(subType != null) {
|
||||
Logger.i(TAG, "StatePlatform.instance.searchChannel(channel.url = ${channel.url}, query = ${query}, subType = ${subType})")
|
||||
pager = StatePlatform.instance.searchChannel(channel.url, query, subType);
|
||||
} else {
|
||||
Logger.i(TAG, "StatePlatform.instance.searchChannel(channel.url = ${channel.url}, query = ${query})")
|
||||
pager = StatePlatform.instance.searchChannel(channel.url, query);
|
||||
}
|
||||
} else {
|
||||
val lastPolycentricProfile = _lastPolycentricProfile;
|
||||
if (lastPolycentricProfile != null && StatePolycentric.instance.enabled) {
|
||||
pager = StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = subType);
|
||||
Logger.i(TAG, "StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = ${subType})")
|
||||
}
|
||||
|
||||
if(pager == null) {
|
||||
if(subType != null)
|
||||
pager = StatePlatform.instance.getChannelContent(channel.url, subType);
|
||||
else
|
||||
pager = StatePlatform.instance.getChannelContent(channel.url);
|
||||
if(pager == null) {
|
||||
if(subType != null) {
|
||||
pager = StatePlatform.instance.getChannelContent(channel.url, subType);
|
||||
Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url}, subType = ${subType})")
|
||||
} else {
|
||||
pager = StatePlatform.instance.getChannelContent(channel.url);
|
||||
Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url})")
|
||||
}
|
||||
}
|
||||
}
|
||||
return pager;
|
||||
}
|
||||
@@ -144,19 +166,49 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
||||
|
||||
_taskLoadVideos.cancel();
|
||||
|
||||
_query = null
|
||||
_channel = channel;
|
||||
updateSearchViewVisibility()
|
||||
_results.clear();
|
||||
_adapterResults?.notifyDataSetChanged();
|
||||
|
||||
loadInitial();
|
||||
}
|
||||
|
||||
private fun updateSearchViewVisibility() {
|
||||
if (subType != null) {
|
||||
_searchView?.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
|
||||
val client = _channel?.id?.pluginId?.let { StatePlatform.instance.getClientOrNull(it) }
|
||||
Logger.i(TAG, "_searchView.visible = ${client?.capabilities?.hasSearchChannelContents == true}")
|
||||
_searchView?.visibility = if (client?.capabilities?.hasSearchChannelContents == true) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
fun setQuery(query: String) {
|
||||
_query = query
|
||||
_taskLoadVideos.cancel()
|
||||
_results.clear()
|
||||
_adapterResults?.notifyDataSetChanged()
|
||||
loadInitial()
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
val view = inflater.inflate(R.layout.fragment_channel_videos, container, false);
|
||||
|
||||
_query = null
|
||||
_recyclerResults = view.findViewById(R.id.recycler_videos);
|
||||
|
||||
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar).apply {
|
||||
val searchView = SearchView(requireContext()).apply { layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT) }.apply {
|
||||
onEnter.subscribe {
|
||||
setQuery(it)
|
||||
}
|
||||
}
|
||||
_searchView = searchView
|
||||
updateSearchViewVisibility()
|
||||
|
||||
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply {
|
||||
this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
|
||||
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
|
||||
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
|
||||
@@ -173,6 +225,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
||||
_recyclerResults?.layoutManager = _glmVideo;
|
||||
_recyclerResults?.addOnScrollListener(_scrollListener);
|
||||
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -181,6 +234,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
||||
_recyclerResults?.removeOnScrollListener(_scrollListener);
|
||||
_recyclerResults = null;
|
||||
_pager = null;
|
||||
_query = null
|
||||
_searchView = null
|
||||
|
||||
_taskLoadVideos.cancel();
|
||||
_nextPageHandler.cancel();
|
||||
@@ -303,6 +358,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
||||
}
|
||||
|
||||
private fun loadInitial() {
|
||||
Logger.i(TAG, "loadInitial")
|
||||
val channel: IPlatformChannel = _channel ?: return;
|
||||
setLoading(true);
|
||||
_taskLoadVideos.run(channel);
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ open class MainActivityFragment : Fragment() {
|
||||
fun navigate(frag: MainFragment, parameter: Any? = null, withHistory: Boolean = true) {
|
||||
val a = activity
|
||||
if (a is MainActivity)
|
||||
(activity as MainActivity).navigate(frag, parameter, withHistory)
|
||||
(activity as MainActivity).navigate(frag, parameter, withHistory, false)
|
||||
else
|
||||
Log.e(TAG, "Failed to navigate due to activity not being a main activity.")
|
||||
}
|
||||
|
||||
+13
-13
@@ -330,7 +330,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
}
|
||||
|
||||
if (!StatePayment.instance.hasPaid) {
|
||||
newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid_filled, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>() }))
|
||||
newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid_filled, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>(withHistory = true) }))
|
||||
}
|
||||
|
||||
//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,20 +383,20 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
currentMain.scrollToTop(false)
|
||||
currentMain.reloadFeed()
|
||||
} else {
|
||||
it.navigate<HomeFragment>()
|
||||
it.navigate<HomeFragment>(withHistory = false)
|
||||
}
|
||||
}),
|
||||
ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate<SubscriptionsFeedFragment>() }),
|
||||
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>() }),
|
||||
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>() }),
|
||||
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>() }),
|
||||
ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>() }),
|
||||
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>() }),
|
||||
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>() }),
|
||||
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>() }),
|
||||
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>() }),
|
||||
ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate<SubscriptionsFeedFragment>(withHistory = false) }),
|
||||
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>(withHistory = false) }),
|
||||
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>(withHistory = false) }),
|
||||
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>(withHistory = false) }),
|
||||
ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>(withHistory = false) }),
|
||||
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>(withHistory = false) }),
|
||||
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }),
|
||||
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }),
|
||||
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }),
|
||||
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
|
||||
val c = it.context ?: return@ButtonDefinition;
|
||||
val c = it.context ?: return@ButtonDefinition;
|
||||
Logger.i(TAG, "settings preventPictureInPicture()");
|
||||
it.requireFragment<VideoDetailFragment>().preventPictureInPicture();
|
||||
val intent = Intent(c, SettingsActivity::class.java);
|
||||
@@ -416,7 +416,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}),
|
||||
ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, {
|
||||
it.navigate<BrowserFragment>(Settings.URL_FAQ);
|
||||
it.navigate<BrowserFragment>(Settings.URL_FAQ, withHistory = false);
|
||||
})
|
||||
//96 is reserved for privacy button
|
||||
//98 is reserved for buy button
|
||||
|
||||
+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 {}
|
||||
}
|
||||
}
|
||||
+19
-13
@@ -1,6 +1,8 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -66,8 +68,7 @@ class BuyFragment : MainFragment() {
|
||||
|
||||
_paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, _, exception ->
|
||||
if(success) {
|
||||
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0,
|
||||
UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY));
|
||||
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0, UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY));
|
||||
_fragment.close(true);
|
||||
}
|
||||
else {
|
||||
@@ -115,11 +116,14 @@ class BuyFragment : MainFragment() {
|
||||
val licenseInput = SlideUpMenuTextInput(context, context.getString(R.string.license));
|
||||
val productLicenseDialog = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_paid), context.getString(R.string.enter_license_key), context.getString(R.string.ok), true, licenseInput);
|
||||
productLicenseDialog.onOK.subscribe {
|
||||
licenseInput.deactivate();
|
||||
val licenseText = licenseInput.text;
|
||||
if (licenseText.isNullOrEmpty()) {
|
||||
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.invalid_license_key));
|
||||
return@subscribe;
|
||||
}
|
||||
licenseInput.clear();
|
||||
productLicenseDialog.hide(true);
|
||||
|
||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
|
||||
@@ -127,17 +131,18 @@ class BuyFragment : MainFragment() {
|
||||
val activationResult = StatePayment.instance.setPaymentLicense(licenseText);
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if(activationResult) {
|
||||
licenseInput.deactivate();
|
||||
licenseInput.clear();
|
||||
productLicenseDialog.hide(true);
|
||||
|
||||
UIDialogs.showDialogOk(context, R.drawable.ic_check, context.getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required));
|
||||
_fragment.close(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.invalid_license_key));
|
||||
try {
|
||||
if(activationResult) {
|
||||
UIDialogs.showDialogOk(context, R.drawable.ic_check, context.getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required)) {
|
||||
_fragment.close(true)
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.invalid_license_key));
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to update UI after buy complete", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,5 +163,6 @@ class BuyFragment : MainFragment() {
|
||||
|
||||
companion object {
|
||||
fun newInstance() = BuyFragment().apply {}
|
||||
private val TAG = "BuyFragment"
|
||||
}
|
||||
}
|
||||
+10
-9
@@ -47,6 +47,7 @@ import com.futo.platformplayer.selectHighestResolutionImage
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.toHumanNumber
|
||||
import com.futo.platformplayer.views.adapters.ChannelTab
|
||||
@@ -135,6 +136,8 @@ class ChannelFragment : MainFragment() {
|
||||
inflater.inflate(R.layout.fragment_channel, this)
|
||||
_taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>({ fragment.lifecycleScope },
|
||||
{ id ->
|
||||
if (!StatePolycentric.instance.enabled)
|
||||
return@TaskHandler null
|
||||
return@TaskHandler ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, id.claimFieldType.toLong(), id.claimType.toLong(), id.value!!)
|
||||
}).success { setPolycentricProfile(it, animate = true) }.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load polycentric profile.", it)
|
||||
@@ -422,17 +425,15 @@ class ChannelFragment : MainFragment() {
|
||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (plugin != null && plugin.capabilities.hasSearchChannelContents) {
|
||||
buttons.add(Pair(R.drawable.ic_search) {
|
||||
_fragment.navigate<SuggestionsFragment>(
|
||||
SuggestionsFragmentData(
|
||||
"", SearchType.VIDEO, channel.url
|
||||
)
|
||||
buttons.add(Pair(R.drawable.ic_search) {
|
||||
_fragment.navigate<SuggestionsFragment>(
|
||||
SuggestionsFragmentData(
|
||||
"", SearchType.VIDEO
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
|
||||
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
|
||||
}
|
||||
if(plugin != null && plugin.capabilities.hasGetChannelCapabilities) {
|
||||
if(plugin.getChannelCapabilities()?.types?.contains(ResultCapabilities.TYPE_SHORTS) ?: false &&
|
||||
!(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(ChannelTab.SHORTS.ordinal.toLong())) {
|
||||
|
||||
+9
@@ -10,12 +10,14 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
@@ -196,7 +198,14 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
fragment.navigate<RemotePlaylistFragment>(content);
|
||||
} else if (content is IPlatformPost) {
|
||||
fragment.navigate<PostDetailFragment>(content);
|
||||
} else if(content is IPlatformArticle) {
|
||||
fragment.navigate<ArticleDetailFragment>(content);
|
||||
}
|
||||
else if(content is JSWeb) {
|
||||
fragment.navigate<WebDetailFragment>(content);
|
||||
}
|
||||
else
|
||||
UIDialogs.appToast("Unknown content type [" + content.contentType.name + "]");
|
||||
}
|
||||
protected open fun onContentUrlClicked(url: String, contentType: ContentType) {
|
||||
when(contentType) {
|
||||
|
||||
+8
-28
@@ -89,7 +89,6 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
private var _sortBy: String? = null;
|
||||
private var _filterValues: HashMap<String, List<String>> = hashMapOf();
|
||||
private var _enabledClientIds: List<String>? = null;
|
||||
private var _channelUrl: String? = null;
|
||||
private var _searchType: SearchType? = null;
|
||||
|
||||
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
|
||||
@@ -98,17 +97,12 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
||||
_taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query ->
|
||||
Logger.i(TAG, "Searching for: $query")
|
||||
val channelUrl = _channelUrl;
|
||||
if (channelUrl != null) {
|
||||
StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds)
|
||||
} else {
|
||||
when (_searchType)
|
||||
{
|
||||
SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
|
||||
SearchType.CREATOR -> StatePlatform.instance.searchChannelsAsContent(query)
|
||||
SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query)
|
||||
else -> throw Exception("Search type must be specified")
|
||||
}
|
||||
when (_searchType)
|
||||
{
|
||||
SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
|
||||
SearchType.CREATOR -> StatePlatform.instance.searchChannelsAsContent(query)
|
||||
SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query)
|
||||
else -> throw Exception("Search type must be specified")
|
||||
}
|
||||
})
|
||||
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
|
||||
@@ -147,7 +141,6 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
fun onShown(parameter: Any?) {
|
||||
if(parameter is SuggestionsFragmentData) {
|
||||
setQuery(parameter.query, false);
|
||||
setChannelUrl(parameter.channelUrl, false);
|
||||
setSearchType(parameter.searchType, false)
|
||||
|
||||
fragment.topBar?.apply {
|
||||
@@ -164,7 +157,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
onFilterClick.subscribe(this) {
|
||||
_overlayContainer.let {
|
||||
val filterValuesCopy = HashMap(_filterValues);
|
||||
val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy, _channelUrl != null);
|
||||
val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy);
|
||||
filtersOverlay.onOK.subscribe { enabledClientIds, changed ->
|
||||
if (changed) {
|
||||
setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy);
|
||||
@@ -211,11 +204,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val commonCapabilities =
|
||||
if(_channelUrl == null)
|
||||
StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
else
|
||||
StatePlatform.instance.getCommonSearchChannelContentsCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
val commonCapabilities = StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
val sorts = commonCapabilities?.sorts ?: listOf();
|
||||
if (sorts.size > 1) {
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -282,15 +271,6 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun setChannelUrl(channelUrl: String?, updateResults: Boolean = true) {
|
||||
_channelUrl = channelUrl;
|
||||
|
||||
if (updateResults) {
|
||||
clearResults();
|
||||
loadResults();
|
||||
}
|
||||
}
|
||||
|
||||
private fun setSearchType(searchType: SearchType, updateResults: Boolean = true) {
|
||||
_searchType = searchType
|
||||
|
||||
|
||||
+5
-1
@@ -150,7 +150,7 @@ class DownloadsFragment : MainFragment() {
|
||||
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also {
|
||||
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||
};
|
||||
val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc");
|
||||
val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc", "sizeAsc", "sizeDesc");
|
||||
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
|
||||
when(pos) {
|
||||
@@ -160,6 +160,8 @@ class DownloadsFragment : MainFragment() {
|
||||
3 -> ordering.setAndSave("downloadDateDesc")
|
||||
4 -> ordering.setAndSave("releasedAsc")
|
||||
5 -> ordering.setAndSave("releasedDesc")
|
||||
6 -> ordering.setAndSave("sizeAsc")
|
||||
7 -> ordering.setAndSave("sizeDesc")
|
||||
else -> ordering.setAndSave("")
|
||||
}
|
||||
updateContentFilters()
|
||||
@@ -257,6 +259,8 @@ class DownloadsFragment : MainFragment() {
|
||||
"nameDesc" -> vidsToReturn.sortedByDescending { it.name.lowercase() }
|
||||
"releasedAsc" -> vidsToReturn.sortedBy { it.datetime ?: OffsetDateTime.MAX }
|
||||
"releasedDesc" -> vidsToReturn.sortedByDescending { it.datetime ?: OffsetDateTime.MIN }
|
||||
"sizeAsc" -> vidsToReturn.sortedBy { it.videoSource.sumOf { it.fileSize } + it.audioSource.sumOf { it.fileSize } }
|
||||
"sizeDesc" -> vidsToReturn.sortedByDescending { it.videoSource.sumOf { it.fileSize } + it.audioSource.sumOf { it.fileSize } }
|
||||
else -> vidsToReturn
|
||||
}
|
||||
}
|
||||
|
||||
+46
-5
@@ -15,6 +15,8 @@ import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
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.IPager
|
||||
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.models.HistoryVideo
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
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.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.others.TagsView
|
||||
import com.futo.platformplayer.views.others.Toggle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -68,6 +74,8 @@ class HistoryFragment : MainFragment() {
|
||||
private var _pager: IPager<HistoryVideo>? = null;
|
||||
private val _results = arrayListOf<HistoryVideo>();
|
||||
private var _loading = false;
|
||||
private val _toggleBar: ToggleBar
|
||||
private var _togglePluginsDisabled = hashSetOf<String>()
|
||||
|
||||
private var _automaticNextPageCounter = 0;
|
||||
|
||||
@@ -79,6 +87,7 @@ class HistoryFragment : MainFragment() {
|
||||
_clearSearch = findViewById(R.id.button_clear_search);
|
||||
_editSearch = findViewById(R.id.edit_search);
|
||||
_tagsView = findViewById(R.id.tags_text);
|
||||
_toggleBar = findViewById(R.id.toggle_bar)
|
||||
_tagsView.setPairs(listOf(
|
||||
Pair(context.getString(R.string.last_hour), 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)
|
||||
));
|
||||
|
||||
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(),
|
||||
{ _results.size },
|
||||
{ view, _ ->
|
||||
@@ -162,14 +187,15 @@ class HistoryFragment : MainFragment() {
|
||||
else
|
||||
it.nextPage();
|
||||
|
||||
return@TaskHandler it.getResults();
|
||||
return@TaskHandler filterResults(it.getResults());
|
||||
}).success {
|
||||
setLoading(false);
|
||||
|
||||
val posBefore = _results.size;
|
||||
_results.addAll(it);
|
||||
_adapter.notifyItemRangeInserted(_adapter.childToParentPosition(posBefore), it.size);
|
||||
ensureEnoughContentVisible(it)
|
||||
val res = filterResults(it)
|
||||
_results.addAll(res);
|
||||
_adapter.notifyItemRangeInserted(_adapter.childToParentPosition(posBefore), res.size);
|
||||
ensureEnoughContentVisible(res)
|
||||
}.exception<Throwable> {
|
||||
Logger.w(TAG, "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() {
|
||||
val query = _editSearch.text.toString();
|
||||
if (_editSearch.text.isNotEmpty()) {
|
||||
@@ -246,11 +276,22 @@ class HistoryFragment : MainFragment() {
|
||||
_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>) {
|
||||
Logger.i(TAG, "Setting new internal pager on feed");
|
||||
|
||||
_results.clear();
|
||||
val toAdd = pager.getResults();
|
||||
val toAdd = filterResults(pager.getResults())
|
||||
_results.addAll(toAdd);
|
||||
_adapter.notifyDataSetChanged();
|
||||
ensureEnoughContentVisible(toAdd)
|
||||
|
||||
+1
-1
@@ -217,7 +217,7 @@ class PlaylistsFragment : MainFragment() {
|
||||
var playlistsToReturn = pls;
|
||||
if(!_listPlaylistsSearch.text.isNullOrEmpty())
|
||||
playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) };
|
||||
if(!_ordering.value.isNullOrEmpty()){
|
||||
if(!_ordering.value.isNullOrEmpty()) {
|
||||
playlistsToReturn = when(_ordering.value){
|
||||
"nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() }
|
||||
"nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() };
|
||||
|
||||
+10
-1
@@ -168,7 +168,12 @@ class PostDetailFragment : MainFragment {
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
|
||||
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
|
||||
|
||||
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) })
|
||||
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, {
|
||||
if (!StatePolycentric.instance.enabled)
|
||||
return@TaskHandler null
|
||||
|
||||
ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!)
|
||||
})
|
||||
.success { it -> setPolycentricProfile(it, animate = true) }
|
||||
.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load claims.", it);
|
||||
@@ -327,6 +332,10 @@ class PostDetailFragment : MainFragment {
|
||||
val version = _version;
|
||||
|
||||
_rating.onLikeDislikeUpdated.remove(this);
|
||||
|
||||
if (!StatePolycentric.instance.enabled)
|
||||
return
|
||||
|
||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
if (version != _version) {
|
||||
return@launch;
|
||||
|
||||
+3
@@ -18,6 +18,7 @@ import com.futo.platformplayer.activities.AddSourceOptionsActivity
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
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.StatePlugins
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
@@ -44,6 +45,7 @@ class SourcesFragment : MainFragment() {
|
||||
if(topBar is AddTopBarFragment) {
|
||||
(topBar as AddTopBarFragment).onAdd.clear();
|
||||
(topBar as AddTopBarFragment).onAdd.subscribe {
|
||||
StateApp.instance.preventPictureInPicture.emit();
|
||||
startActivity(Intent(requireContext(), AddSourceOptionsActivity::class.java));
|
||||
};
|
||||
}
|
||||
@@ -93,6 +95,7 @@ class SourcesFragment : MainFragment() {
|
||||
findViewById<LinearLayout>(R.id.plugin_disclaimer).isVisible = false;
|
||||
}
|
||||
findViewById<BigButton>(R.id.button_add_sources).onClick.subscribe {
|
||||
StateApp.instance.preventPictureInPicture.emit();
|
||||
fragment.startActivity(Intent(context, AddSourceOptionsActivity::class.java));
|
||||
};
|
||||
|
||||
|
||||
+2
-2
@@ -290,8 +290,8 @@ class SubscriptionGroupFragment : MainFragment() {
|
||||
image.setImageView(_imageGroup);
|
||||
}
|
||||
else {
|
||||
_imageGroupBackground.setImageResource(0);
|
||||
_imageGroup.setImageResource(0);
|
||||
_imageGroupBackground.setImageDrawable(null);
|
||||
_imageGroup.setImageDrawable(null);
|
||||
}
|
||||
updateMeta();
|
||||
reloadCreators(group);
|
||||
|
||||
+2
-2
@@ -191,7 +191,7 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
|
||||
private var _bypassRateLimit = false;
|
||||
private val _lastExceptions: List<Throwable>? = null;
|
||||
private val _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({StateApp.instance.scope}, { withRefresh ->
|
||||
private val _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({fragment.lifecycleScope}, { withRefresh ->
|
||||
val group = subGroup;
|
||||
if(!_bypassRateLimit) {
|
||||
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount(group);
|
||||
@@ -202,7 +202,7 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
throw RateLimitException(rateLimitPlugins.map { it.key.id });
|
||||
}
|
||||
_bypassRateLimit = false;
|
||||
val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh, group);
|
||||
val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(fragment.lifecycleScope, withRefresh, group);
|
||||
val feed = StateSubscriptions.instance.getFeed(group?.id);
|
||||
|
||||
val currentExs = feed?.exceptions ?: listOf();
|
||||
|
||||
+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.TagsView
|
||||
|
||||
data class SuggestionsFragmentData(val query: String, val searchType: SearchType, val channelUrl: String? = null);
|
||||
data class SuggestionsFragmentData(val query: String, val searchType: SearchType);
|
||||
|
||||
class SuggestionsFragment : MainFragment {
|
||||
override val isMainView : Boolean = true;
|
||||
@@ -34,7 +34,6 @@ class SuggestionsFragment : MainFragment {
|
||||
private val _suggestions: ArrayList<String> = ArrayList();
|
||||
private var _query: String? = null;
|
||||
private var _searchType: SearchType = SearchType.VIDEO;
|
||||
private var _channelUrl: String? = null;
|
||||
|
||||
private val _adapterSuggestions = SearchSuggestionAdapter(_suggestions);
|
||||
|
||||
@@ -52,7 +51,7 @@ class SuggestionsFragment : MainFragment {
|
||||
_adapterSuggestions.onClicked.subscribe { suggestion ->
|
||||
val storage = FragmentedStorage.get<SearchHistoryStorage>();
|
||||
storage.add(suggestion);
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, _searchType, _channelUrl));
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, _searchType));
|
||||
}
|
||||
_adapterSuggestions.onRemove.subscribe { suggestion ->
|
||||
val index = _suggestions.indexOf(suggestion);
|
||||
@@ -109,10 +108,8 @@ class SuggestionsFragment : MainFragment {
|
||||
|
||||
if (parameter is SuggestionsFragmentData) {
|
||||
_searchType = parameter.searchType;
|
||||
_channelUrl = parameter.channelUrl;
|
||||
} else if (parameter is SearchType) {
|
||||
_searchType = parameter;
|
||||
_channelUrl = null;
|
||||
}
|
||||
|
||||
_radioGroupView?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true)
|
||||
@@ -135,7 +132,7 @@ class SuggestionsFragment : MainFragment {
|
||||
}
|
||||
}
|
||||
else
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, _searchType, _channelUrl));
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, _searchType));
|
||||
};
|
||||
|
||||
onTextChange.subscribe(this) {
|
||||
|
||||
+16
-4
@@ -455,6 +455,10 @@ class VideoDetailFragment() : MainFragment() {
|
||||
activity?.enterPictureInPictureMode(params);
|
||||
}
|
||||
}
|
||||
|
||||
if (isFullscreen) {
|
||||
viewDetail?.restoreBrightness()
|
||||
}
|
||||
}
|
||||
|
||||
fun forcePictureInPicture() {
|
||||
@@ -463,10 +467,14 @@ class VideoDetailFragment() : MainFragment() {
|
||||
activity?.enterPictureInPictureMode(params);
|
||||
}
|
||||
fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, isStop: Boolean, newConfig: Configuration) {
|
||||
if (isInPictureInPictureMode) {
|
||||
_viewDetail?.startPictureInPicture();
|
||||
} else if (isInPictureInPicture) {
|
||||
leavePictureInPictureMode(isStop);
|
||||
try {
|
||||
if (isInPictureInPictureMode) {
|
||||
_viewDetail?.startPictureInPicture();
|
||||
} else if (isInPictureInPicture) {
|
||||
leavePictureInPictureMode(isStop);
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to handle onPictureInPictureModeChanged", e)
|
||||
}
|
||||
}
|
||||
fun leavePictureInPictureMode(isStop: Boolean) {
|
||||
@@ -487,6 +495,10 @@ class VideoDetailFragment() : MainFragment() {
|
||||
_isActive = true;
|
||||
_leavingPiP = false;
|
||||
|
||||
if (isFullscreen) {
|
||||
_viewDetail?.saveBrightness()
|
||||
}
|
||||
|
||||
_viewDetail?.let {
|
||||
Logger.v(TAG, "onResume preventPictureInPicture=false");
|
||||
it.preventPictureInPicture = false;
|
||||
|
||||
+106
-60
@@ -46,6 +46,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.LiveChatManager
|
||||
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.RoundButtonGroup
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
import com.futo.platformplayer.views.segments.ChaptersList
|
||||
import com.futo.platformplayer.views.segments.CommentsList
|
||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||
import com.futo.platformplayer.views.video.FutoVideoPlayer
|
||||
@@ -571,7 +571,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_player.setIsReplay(true);
|
||||
|
||||
val searchVideo = StatePlayer.instance.getCurrentQueueItem();
|
||||
if (searchVideo is SerializedPlatformVideo?) {
|
||||
if (searchVideo is SerializedPlatformVideo? && Settings.instance.playback.deleteFromWatchLaterAuto) {
|
||||
searchVideo?.let { StatePlaylists.instance.removeFromWatchLater(it) };
|
||||
}
|
||||
|
||||
@@ -619,6 +619,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
loadCurrentVideo(lastPositionMilliseconds);
|
||||
updatePillButtonVisibilities();
|
||||
setCastEnabled(false);
|
||||
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
@@ -647,6 +648,15 @@ class VideoDetailView : ConstraintLayout {
|
||||
_timeBar.setDuration(video?.duration ?: 0);
|
||||
}
|
||||
};
|
||||
|
||||
_cast.onTimeJobTimeChanged_s.subscribe {
|
||||
if (_isCasting) {
|
||||
setLastPositionMilliseconds((it * 1000.0).toLong(), true);
|
||||
_timeBar.setPosition(it);
|
||||
_timeBar.setBufferedPosition(0);
|
||||
_timeBar.setDuration(video?.duration ?: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_playerProgress.player = _player.exoPlayer?.player;
|
||||
@@ -688,6 +698,20 @@ class VideoDetailView : ConstraintLayout {
|
||||
Logger.i(TAG, "MediaControlReceiver.onCloseReceived")
|
||||
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); };
|
||||
|
||||
_container_content_description.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
@@ -767,6 +791,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_lastAudioSource = null;
|
||||
_lastSubtitleSource = null;
|
||||
video = null;
|
||||
_container_content_liveChat?.close();
|
||||
_player.clear();
|
||||
cleanupPlaybackTracker();
|
||||
Logger.i(TAG, "Keep screen on unset onClose")
|
||||
@@ -1090,7 +1115,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
when (Settings.instance.playback.backgroundPlay) {
|
||||
0 -> handlePause();
|
||||
1 -> {
|
||||
if(!(video?.isLive ?: false) && Settings.instance.playback.backgroundSwitchToAudio)
|
||||
if(!(video?.isLive ?: false))
|
||||
_player.switchToAudioMode();
|
||||
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||
}
|
||||
@@ -1141,6 +1166,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
MediaControlReceiver.onNextReceived.remove(this);
|
||||
MediaControlReceiver.onPreviousReceived.remove(this);
|
||||
MediaControlReceiver.onCloseReceived.remove(this);
|
||||
MediaControlReceiver.onBackgroundReceived.remove(this);
|
||||
MediaControlReceiver.onSeekToReceived.remove(this);
|
||||
|
||||
val job = _jobHideResume;
|
||||
@@ -1510,60 +1536,68 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_rating.visibility = View.GONE;
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val queryReferencesResponse = ApiMethods.getQueryReferences(
|
||||
ApiMethods.SERVER, ref, null, null,
|
||||
arrayListOf(
|
||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||
.setFromType(ContentType.OPINION.value).setValue(
|
||||
ByteString.copyFrom(Opinion.like.data)
|
||||
).build(),
|
||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||
.setFromType(ContentType.OPINION.value).setValue(
|
||||
ByteString.copyFrom(Opinion.dislike.data)
|
||||
).build()
|
||||
),
|
||||
extraByteReferences = listOfNotNull(extraBytesRef)
|
||||
);
|
||||
if (StatePolycentric.instance.enabled) {
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val queryReferencesResponse = ApiMethods.getQueryReferences(
|
||||
ApiMethods.SERVER, ref, null, null,
|
||||
arrayListOf(
|
||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||
.setFromType(ContentType.OPINION.value).setValue(
|
||||
ByteString.copyFrom(Opinion.like.data)
|
||||
).build(),
|
||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
|
||||
.setFromType(ContentType.OPINION.value).setValue(
|
||||
ByteString.copyFrom(Opinion.dislike.data)
|
||||
).build()
|
||||
),
|
||||
extraByteReferences = listOfNotNull(extraBytesRef)
|
||||
);
|
||||
|
||||
val likes = queryReferencesResponse.countsList[0];
|
||||
val dislikes = queryReferencesResponse.countsList[1];
|
||||
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
|
||||
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
|
||||
val likes = queryReferencesResponse.countsList[0];
|
||||
val dislikes = queryReferencesResponse.countsList[1];
|
||||
val hasLiked =
|
||||
StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
|
||||
val hasDisliked =
|
||||
StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
_rating.visibility = View.VISIBLE;
|
||||
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
|
||||
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
|
||||
if (args.hasLiked) {
|
||||
args.processHandle.opinion(ref, Opinion.like);
|
||||
} else if (args.hasDisliked) {
|
||||
args.processHandle.opinion(ref, Opinion.dislike);
|
||||
} else {
|
||||
args.processHandle.opinion(ref, Opinion.neutral);
|
||||
}
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
args.processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
_rating.visibility = View.VISIBLE;
|
||||
_rating.setRating(
|
||||
RatingLikeDislikes(likes, dislikes),
|
||||
hasLiked,
|
||||
hasDisliked
|
||||
);
|
||||
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
|
||||
if (args.hasLiked) {
|
||||
args.processHandle.opinion(ref, Opinion.like);
|
||||
} else if (args.hasDisliked) {
|
||||
args.processHandle.opinion(ref, Opinion.dislike);
|
||||
} else {
|
||||
args.processHandle.opinion(ref, Opinion.neutral);
|
||||
}
|
||||
}
|
||||
|
||||
StatePolycentric.instance.updateLikeMap(
|
||||
ref,
|
||||
args.hasLiked,
|
||||
args.hasDisliked
|
||||
)
|
||||
};
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
args.processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers", e)
|
||||
}
|
||||
}
|
||||
|
||||
StatePolycentric.instance.updateLikeMap(
|
||||
ref,
|
||||
args.hasLiked,
|
||||
args.hasDisliked
|
||||
)
|
||||
};
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
|
||||
_rating.visibility = View.GONE;
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
|
||||
_rating.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2413,6 +2447,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
Logger.i(TAG, "handleFullScreen(fullscreen=$fullscreen)")
|
||||
|
||||
if(fullscreen) {
|
||||
_container_content.visibility = GONE
|
||||
_layoutPlayerContainer.setPadding(0, 0, 0, 0);
|
||||
|
||||
val lp = _container_content.layoutParams as LayoutParams;
|
||||
@@ -2426,6 +2461,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
setProgressBarOverlayed(null);
|
||||
}
|
||||
else {
|
||||
_container_content.visibility = VISIBLE
|
||||
_layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt());
|
||||
|
||||
val lp = _container_content.layoutParams as LayoutParams;
|
||||
@@ -2485,6 +2521,13 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
}
|
||||
|
||||
fun saveBrightness() {
|
||||
_player.gestureControl.saveBrightness()
|
||||
}
|
||||
fun restoreBrightness() {
|
||||
_player.gestureControl.restoreBrightness()
|
||||
}
|
||||
|
||||
fun setFullscreen(fullscreen : Boolean) {
|
||||
Logger.i(TAG, "setFullscreen(fullscreen=$fullscreen)")
|
||||
_player.setFullScreen(fullscreen)
|
||||
@@ -2648,9 +2691,10 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
|
||||
onChannelClicked.subscribe {
|
||||
if(it.url.isNotBlank())
|
||||
if(it.url.isNotBlank()) {
|
||||
fragment.minimizeVideoDetail()
|
||||
fragment.navigate<ChannelFragment>(it)
|
||||
else
|
||||
} else
|
||||
UIDialogs.appToast("No author url present");
|
||||
}
|
||||
|
||||
@@ -2725,10 +2769,11 @@ class VideoDetailView : ConstraintLayout {
|
||||
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));
|
||||
|
||||
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()
|
||||
.setAspectRatio(Rational(videoSourceWidth, videoSourceHeight))
|
||||
.setSourceRectHint(r)
|
||||
.setActions(listOf(playpauseAction))
|
||||
.setActions(listOf(toBackgroundAction, playpauseAction))
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -3041,7 +3086,12 @@ class VideoDetailView : ConstraintLayout {
|
||||
Logger.w(TAG, "Failed to load recommendations.", it);
|
||||
};
|
||||
|
||||
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) })
|
||||
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, {
|
||||
if (!StatePolycentric.instance.enabled)
|
||||
return@TaskHandler null
|
||||
|
||||
ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!)
|
||||
})
|
||||
.success { it -> setPolycentricProfile(it, animate = true) }
|
||||
.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load claims.", it);
|
||||
@@ -3113,10 +3163,6 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
fun applyFragment(frag: VideoDetailFragment) {
|
||||
fragment = frag;
|
||||
fragment.onMinimize.subscribe {
|
||||
_liveChat?.stop();
|
||||
_container_content_liveChat.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -224,7 +224,8 @@ abstract class VideoListEditorView : LinearLayout {
|
||||
|
||||
fun updateVideoFilters() {
|
||||
val videos = _loadedVideos ?: return;
|
||||
_videoListEditorView.setVideos(filterVideos(videos), _loadedVideosCanEdit);
|
||||
val filteredVideos = filterVideos(videos)
|
||||
_videoListEditorView.setVideos(filteredVideos, _loadedVideosCanEdit && filteredVideos.size == videos.size);
|
||||
}
|
||||
|
||||
protected fun setButtonDownloadVisible(isVisible: Boolean) {
|
||||
|
||||
+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) {
|
||||
this.setText(parameter.query);
|
||||
_searchType = parameter.searchType;
|
||||
_channelUrl = parameter.channelUrl;
|
||||
}
|
||||
|
||||
if(currentMain is SuggestionsFragment)
|
||||
@@ -114,7 +113,7 @@ class SearchTopBarFragment : TopFragment() {
|
||||
fun clear() {
|
||||
_editSearch?.text?.clear();
|
||||
if (currentMain !is SuggestionsFragment) {
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", _searchType, _channelUrl), false);
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", _searchType), false);
|
||||
} else {
|
||||
onSearch.emit("");
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ data class ImageVariable(
|
||||
Glide.with(imageView)
|
||||
.load(bitmap)
|
||||
.into(imageView)
|
||||
} else if(resId != null) {
|
||||
} else if(resId != null && resId > 0) {
|
||||
Glide.with(imageView)
|
||||
.load(resId)
|
||||
.into(imageView)
|
||||
|
||||
@@ -113,7 +113,7 @@ class LoginWebViewClient : WebViewClient {
|
||||
//val domainParts = domain!!.split(".");
|
||||
//val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||
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 ->
|
||||
val cookies = cookieString.split(";");
|
||||
for(cookieStr in cookies) {
|
||||
|
||||
@@ -67,7 +67,7 @@ class WebViewRequirementExtractor {
|
||||
if(cookieString != null) {
|
||||
//val domainParts = domain!!.split(".");
|
||||
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 ->
|
||||
val cookies = cookieString.split(";");
|
||||
for(cookieStr in cookies) {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package com.futo.platformplayer.parsers
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory
|
||||
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
|
||||
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
|
||||
@@ -7,12 +13,15 @@ import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudi
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.toYesNo
|
||||
import com.futo.platformplayer.yesNoToBoolean
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.net.URI
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.text.ifEmpty
|
||||
|
||||
class HLS {
|
||||
companion object {
|
||||
@OptIn(UnstableApi::class)
|
||||
fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist {
|
||||
val baseUrl = URI(sourceUrl).resolve("./").toString()
|
||||
|
||||
@@ -49,6 +58,31 @@ class HLS {
|
||||
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments)
|
||||
}
|
||||
|
||||
fun mediaRenditionToVariant(rendition: MediaRendition): HLSVariantAudioUrlSource? {
|
||||
if (rendition.uri == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val suffix = listOf(rendition.language, rendition.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
|
||||
return when (rendition.type) {
|
||||
"AUDIO" -> HLSVariantAudioUrlSource(rendition.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", rendition.language ?: "", null, false, false, rendition.uri)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun variantReferenceToVariant(reference: VariantPlaylistReference): HLSVariantVideoUrlSource {
|
||||
var width: Int? = null
|
||||
var height: Int? = null
|
||||
val resolutionTokens = reference.streamInfo.resolution?.split('x')
|
||||
if (resolutionTokens?.isNotEmpty() == true) {
|
||||
width = resolutionTokens[0].toIntOrNull()
|
||||
height = resolutionTokens[1].toIntOrNull()
|
||||
}
|
||||
|
||||
val suffix = listOf(reference.streamInfo.video, reference.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
|
||||
return HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", reference.streamInfo.codecs ?: "", reference.streamInfo.bandwidth, 0, false, reference.url)
|
||||
}
|
||||
|
||||
fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist {
|
||||
val lines = content.lines()
|
||||
val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull()
|
||||
@@ -61,7 +95,25 @@ class HLS {
|
||||
val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":")
|
||||
val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) }
|
||||
|
||||
val keyInfo =
|
||||
lines.find { it.startsWith("#EXT-X-KEY:") }?.substringAfter(":")?.split(",")
|
||||
|
||||
val key = keyInfo?.find { it.startsWith("URI=") }?.substringAfter("=")?.trim('"')
|
||||
val iv =
|
||||
keyInfo?.find { it.startsWith("IV=") }?.substringAfter("=")?.substringAfter("x")
|
||||
|
||||
val decryptionInfo: DecryptionInfo? = key?.let { k ->
|
||||
DecryptionInfo(k, iv)
|
||||
}
|
||||
|
||||
val initSegment =
|
||||
lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0)
|
||||
?.substringAfter("=")?.trim('"')
|
||||
val segments = mutableListOf<Segment>()
|
||||
if (initSegment != null) {
|
||||
segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment)))
|
||||
}
|
||||
|
||||
var currentSegment: MediaSegment? = null
|
||||
lines.forEach { line ->
|
||||
when {
|
||||
@@ -86,7 +138,7 @@ class HLS {
|
||||
}
|
||||
}
|
||||
|
||||
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments)
|
||||
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments, decryptionInfo)
|
||||
}
|
||||
|
||||
fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> {
|
||||
@@ -270,7 +322,7 @@ class HLS {
|
||||
val name: String?,
|
||||
val isDefault: Boolean?,
|
||||
val isAutoSelect: Boolean?,
|
||||
val isForced: Boolean?
|
||||
val isForced: Boolean?,
|
||||
) {
|
||||
fun toM3U8Line(): String = buildString {
|
||||
append("#EXT-X-MEDIA:")
|
||||
@@ -319,30 +371,13 @@ class HLS {
|
||||
|
||||
fun getVideoSources(): List<HLSVariantVideoUrlSource> {
|
||||
return variantPlaylistsRefs.map {
|
||||
var width: Int? = null
|
||||
var height: Int? = null
|
||||
val resolutionTokens = it.streamInfo.resolution?.split('x')
|
||||
if (resolutionTokens?.isNotEmpty() == true) {
|
||||
width = resolutionTokens[0].toIntOrNull()
|
||||
height = resolutionTokens[1].toIntOrNull()
|
||||
}
|
||||
|
||||
val suffix = listOf(it.streamInfo.video, it.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
|
||||
HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs ?: "", it.streamInfo.bandwidth, 0, false, it.url)
|
||||
variantReferenceToVariant(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAudioSources(): List<HLSVariantAudioUrlSource> {
|
||||
return mediaRenditions.mapNotNull {
|
||||
if (it.uri == null) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
|
||||
return@mapNotNull when (it.type) {
|
||||
"AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, false, it.uri)
|
||||
else -> null
|
||||
}
|
||||
return@mapNotNull mediaRenditionToVariant(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +403,11 @@ class HLS {
|
||||
}
|
||||
}
|
||||
|
||||
data class DecryptionInfo(
|
||||
val keyUrl: String,
|
||||
val iv: String?
|
||||
)
|
||||
|
||||
data class VariantPlaylist(
|
||||
val version: Int?,
|
||||
val targetDuration: Int?,
|
||||
@@ -376,7 +416,8 @@ class HLS {
|
||||
val programDateTime: ZonedDateTime?,
|
||||
val playlistType: String?,
|
||||
val streamInfo: StreamInfo?,
|
||||
val segments: List<Segment>
|
||||
val segments: List<Segment>,
|
||||
val decryptionInfo: DecryptionInfo? = null
|
||||
) {
|
||||
fun buildM3U8(): String = buildString {
|
||||
append("#EXTM3U\n")
|
||||
|
||||
@@ -21,6 +21,7 @@ class MediaControlReceiver : BroadcastReceiver() {
|
||||
EVENT_NEXT -> onNextReceived.emit();
|
||||
EVENT_PREV -> onPreviousReceived.emit();
|
||||
EVENT_CLOSE -> onCloseReceived.emit();
|
||||
EVENT_BACKGROUND -> onBackgroundReceived.emit();
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
@@ -38,6 +39,7 @@ class MediaControlReceiver : BroadcastReceiver() {
|
||||
const val EVENT_NEXT = "Next";
|
||||
const val EVENT_PREV = "Prev";
|
||||
const val EVENT_CLOSE = "Close";
|
||||
const val EVENT_BACKGROUND = "Background";
|
||||
|
||||
val onPlayReceived = Event0();
|
||||
val onPauseReceived = Event0();
|
||||
@@ -48,6 +50,7 @@ class MediaControlReceiver : BroadcastReceiver() {
|
||||
val onLowerVolumeReceived = 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 {
|
||||
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 {
|
||||
this.putExtra(EXTRA_MEDIA_ACTION, EVENT_CLOSE);
|
||||
},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
|
||||
|
||||
import com.futo.platformplayer.sToOffsetDateTimeUTC
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
@@ -37,7 +38,7 @@ class OffsetDateTimeSerializer : KSerializer<OffsetDateTime> {
|
||||
return OffsetDateTime.MAX;
|
||||
else if(epochSecond < -9999999999)
|
||||
return OffsetDateTime.MIN;
|
||||
return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
||||
return epochSecond.sToOffsetDateTimeUTC()
|
||||
}
|
||||
}
|
||||
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.MainActivity
|
||||
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.JSClient
|
||||
import com.futo.platformplayer.background.BackgroundWorker
|
||||
@@ -414,6 +415,14 @@ class StateApp {
|
||||
StateSync.instance.start(context)
|
||||
}
|
||||
|
||||
settingsActivityClosed.subscribe {
|
||||
if (Settings.instance.synchronization.enabled) {
|
||||
StateSync.instance.start(context)
|
||||
} else {
|
||||
StateSync.instance.stop()
|
||||
}
|
||||
}
|
||||
|
||||
Logger.onLogSubmitted.subscribe {
|
||||
scopeOrNull?.launch(Dispatchers.Main) {
|
||||
try {
|
||||
@@ -509,10 +518,17 @@ class StateApp {
|
||||
|
||||
//Migration
|
||||
Logger.i(TAG, "MainApp Started: Check [Migrations]");
|
||||
migrateStores(context, listOf(
|
||||
StateSubscriptions.instance.toMigrateCheck(),
|
||||
StatePlaylists.instance.toMigrateCheck()
|
||||
).flatten(), 0);
|
||||
|
||||
scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
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) {
|
||||
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)
|
||||
return;
|
||||
val store = managedStores[index];
|
||||
if(store.hasMissingReconstructions())
|
||||
UIDialogs.showMigrateDialog(context, store) {
|
||||
migrateStores(context, managedStores, index + 1);
|
||||
};
|
||||
else
|
||||
if(store.hasMissingReconstructions()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
try {
|
||||
UIDialogs.showMigrateDialog(context, store) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -707,6 +735,7 @@ class StateApp {
|
||||
|
||||
StatePlayer.instance.closeMediaSession();
|
||||
StateCasting.instance.stop();
|
||||
StateSync.instance.stop();
|
||||
StatePlayer.dispose();
|
||||
Companion.dispose();
|
||||
_fileLogConsumer?.close();
|
||||
|
||||
@@ -383,7 +383,7 @@ class StateDownloads {
|
||||
}
|
||||
private fun validateDownload(videoState: VideoDownload) {
|
||||
if(_downloading.hasItem { it.videoEither.url == videoState.videoEither.url })
|
||||
throw IllegalStateException("Video [${videoState.name}] is already queued for dowload");
|
||||
throw IllegalStateException("Video [${videoState.name}] is already queued for download");
|
||||
|
||||
val existing = getCachedVideo(videoState.id);
|
||||
if(existing != null) {
|
||||
|
||||
@@ -131,8 +131,13 @@ class StateHistory {
|
||||
fun getHistoryPager(): IPager<HistoryVideo> {
|
||||
return _historyDBStore.getObjectPager();
|
||||
}
|
||||
fun getHistorySearchPager(query: String): IPager<HistoryVideo> {
|
||||
return _historyDBStore.queryLikeObjectPager(DBHistory.Index::name, "%${query}%", 10);
|
||||
fun getHistorySearchPager(query: String, withAuthor: Boolean = false): IPager<HistoryVideo> {
|
||||
return if(!withAuthor)
|
||||
_historyDBStore.queryLikeObjectPager(DBHistory.Index::name, "%${query}%", 10)
|
||||
else
|
||||
_historyDBStore.queryLikeObjectPager(DBHistory.Index::name, "%${query}%", 10)
|
||||
//_historyDBStore.queryLike2ObjectPager(DBHistory.Index::name, DBHistory.Index::auth,"%${query}%", 10)
|
||||
//TODO: See if we can include author name?
|
||||
}
|
||||
fun getHistoryIndexByUrl(url: String): DBHistory.Index? {
|
||||
return historyIndex[url];
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.collection.LruCache
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.PlatformMultiClientPool
|
||||
@@ -46,7 +45,6 @@ import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringArrayStorage
|
||||
import com.futo.platformplayer.stores.StringStorage
|
||||
import com.futo.platformplayer.views.ToastView
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
@@ -56,7 +54,6 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.internal.concat
|
||||
import java.lang.Thread.sleep
|
||||
import java.time.OffsetDateTime
|
||||
import kotlin.streams.asSequence
|
||||
@@ -94,9 +91,11 @@ class StatePlatform {
|
||||
private val _trackerClientPool = PlatformMultiClientPool("Trackers", 1); //Used exclusively for playback trackers
|
||||
private val _liveEventClientPool = PlatformMultiClientPool("LiveEvents", 1); //Used exclusively for live events
|
||||
private val _privateClientPool = PlatformMultiClientPool("Private", 2, true); //Used primarily for calls if in incognito mode
|
||||
private val _instantClientPool = PlatformMultiClientPool("Instant", 1, false, true); //Used for all instant calls
|
||||
|
||||
|
||||
private val _icons : HashMap<String, ImageVariable> = HashMap();
|
||||
private val _iconsByName : HashMap<String, ImageVariable> = HashMap();
|
||||
|
||||
val hasClients: Boolean get() = _availableClients.size > 0;
|
||||
|
||||
@@ -113,14 +112,14 @@ class StatePlatform {
|
||||
|
||||
Logger.i(StatePlatform::class.java.name, "Fetching video details [${url}]");
|
||||
if(!StateApp.instance.privateMode) {
|
||||
_enabledClients.find { it.isContentDetailsUrl(url) }?.let {
|
||||
_enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let {
|
||||
_mainClientPool.getClientPooled(it).getContentDetails(url)
|
||||
}
|
||||
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
|
||||
}
|
||||
else {
|
||||
Logger.i(TAG, "Fetching details with private client");
|
||||
_enabledClients.find { it.isContentDetailsUrl(url) }?.let {
|
||||
_enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let {
|
||||
_privateClientPool.getClientPooled(it).getContentDetails(url)
|
||||
}
|
||||
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
|
||||
@@ -192,6 +191,7 @@ class StatePlatform {
|
||||
_availableClients.clear();
|
||||
|
||||
_icons.clear();
|
||||
_iconsByName.clear()
|
||||
_icons[StateDeveloper.DEV_ID] = ImageVariable(null, R.drawable.ic_security_red);
|
||||
|
||||
StatePlugins.instance.updateEmbeddedPlugins(context);
|
||||
@@ -200,6 +200,8 @@ class StatePlatform {
|
||||
for (plugin in StatePlugins.instance.getPlugins()) {
|
||||
_icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?:
|
||||
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);
|
||||
client.onCaptchaException.subscribe { c, ex ->
|
||||
@@ -299,6 +301,15 @@ class StatePlatform {
|
||||
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>) {
|
||||
_platformOrderPersistent.values.clear();
|
||||
_platformOrderPersistent.values.addAll(platformOrder);
|
||||
@@ -655,10 +666,10 @@ class StatePlatform {
|
||||
|
||||
|
||||
//Video
|
||||
fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { it.isContentDetailsUrl(url) };
|
||||
fun hasEnabledContentClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) };
|
||||
fun getContentClient(url: String) : IPlatformClient = getContentClientOrNull(url)
|
||||
?: throw NoPlatformClientException("No client enabled that supports this content url (${url})");
|
||||
fun getContentClientOrNull(url: String) : IPlatformClient? = getEnabledClients().find { it.isContentDetailsUrl(url) };
|
||||
fun getContentClientOrNull(url: String) : IPlatformClient? = getEnabledClients().find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) };
|
||||
fun getContentDetails(url: String, forceRefetch: Boolean = false): Deferred<IPlatformContentDetails> {
|
||||
Logger.i(TAG, "Platform - getContentDetails (${url})");
|
||||
if(forceRefetch)
|
||||
@@ -699,14 +710,14 @@ class StatePlatform {
|
||||
return client.getContentRecommendations(url);
|
||||
}
|
||||
|
||||
fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { it.isChannelUrl(url) };
|
||||
fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isChannelUrl(url) };
|
||||
fun getChannelClient(url : String, exclude: List<String>? = null) : IPlatformClient = getChannelClientOrNull(url, exclude)
|
||||
?: throw NoPlatformClientException("No client enabled that supports this channel url (${url})");
|
||||
fun getChannelClientOrNull(url : String, exclude: List<String>? = null) : IPlatformClient? =
|
||||
if(exclude == null)
|
||||
getEnabledClients().find { it.isChannelUrl(url) }
|
||||
getEnabledClients().find { _instantClientPool.getClientPooled(it).isChannelUrl(url) }
|
||||
else
|
||||
getEnabledClients().find { !exclude.contains(it.id) && it.isChannelUrl(url) };
|
||||
getEnabledClients().find { !exclude.contains(it.id) && _instantClientPool.getClientPooled(it).isChannelUrl(url) };
|
||||
|
||||
fun getChannel(url: String, updateSubscriptions: Boolean = true): Deferred<IPlatformChannel> {
|
||||
Logger.i(TAG, "Platform - getChannel");
|
||||
@@ -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 client = if(usePooledClients > 1)
|
||||
_channelClientPool.getClientPooled(baseClient, usePooledClients);
|
||||
@@ -727,66 +738,75 @@ class StatePlatform {
|
||||
var lastStream: OffsetDateTime? = null;
|
||||
|
||||
val pagerResult: IPager<IPlatformContent>;
|
||||
if(!clientCapabilities.hasType(ResultCapabilities.TYPE_MIXED) &&
|
||||
( clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS) ||
|
||||
clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS) ||
|
||||
clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE) ||
|
||||
clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS)
|
||||
)) {
|
||||
val toQuery = mutableListOf<String>();
|
||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
||||
toQuery.add(ResultCapabilities.TYPE_VIDEOS);
|
||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS))
|
||||
toQuery.add(ResultCapabilities.TYPE_STREAMS);
|
||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE))
|
||||
toQuery.add(ResultCapabilities.TYPE_LIVE);
|
||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS))
|
||||
toQuery.add(ResultCapabilities.TYPE_POSTS);
|
||||
if (type == null) {
|
||||
if(!clientCapabilities.hasType(ResultCapabilities.TYPE_MIXED) &&
|
||||
( clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS) ||
|
||||
clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS) ||
|
||||
clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE) ||
|
||||
clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS)
|
||||
)) {
|
||||
val toQuery = mutableListOf<String>();
|
||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
||||
toQuery.add(ResultCapabilities.TYPE_VIDEOS);
|
||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS))
|
||||
toQuery.add(ResultCapabilities.TYPE_STREAMS);
|
||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE))
|
||||
toQuery.add(ResultCapabilities.TYPE_LIVE);
|
||||
if(clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS))
|
||||
toQuery.add(ResultCapabilities.TYPE_POSTS);
|
||||
|
||||
if(isSubscriptionOptimized) {
|
||||
val sub = StateSubscriptions.instance.getSubscription(channelUrl);
|
||||
if(sub != null) {
|
||||
if(!sub.shouldFetchStreams()) {
|
||||
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 7 days, skipping live streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
|
||||
toQuery.remove(ResultCapabilities.TYPE_LIVE);
|
||||
}
|
||||
if(!sub.shouldFetchLiveStreams()) {
|
||||
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
|
||||
toQuery.remove(ResultCapabilities.TYPE_STREAMS);
|
||||
}
|
||||
if(!sub.shouldFetchPosts()) {
|
||||
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 5 days, skipping posts [${sub.lastPost.getNowDiffDays()} days ago]");
|
||||
toQuery.remove(ResultCapabilities.TYPE_POSTS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Merged pager
|
||||
val pagers = toQuery
|
||||
.parallelStream()
|
||||
.map {
|
||||
val results = client.getChannelContents(channelUrl, it, ResultCapabilities.ORDER_CHONOLOGICAL) ;
|
||||
|
||||
when(it) {
|
||||
ResultCapabilities.TYPE_STREAMS -> {
|
||||
val streamResults = results.getResults();
|
||||
if(streamResults.size == 0)
|
||||
lastStream = OffsetDateTime.MIN;
|
||||
else
|
||||
lastStream = results.getResults().firstOrNull()?.datetime;
|
||||
if(isSubscriptionOptimized) {
|
||||
val sub = StateSubscriptions.instance.getSubscription(channelUrl);
|
||||
if(sub != null) {
|
||||
if(!sub.shouldFetchStreams()) {
|
||||
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 7 days, skipping live streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
|
||||
toQuery.remove(ResultCapabilities.TYPE_LIVE);
|
||||
}
|
||||
if(!sub.shouldFetchLiveStreams()) {
|
||||
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${sub.lastLiveStream.getNowDiffDays()} days ago]");
|
||||
toQuery.remove(ResultCapabilities.TYPE_STREAMS);
|
||||
}
|
||||
if(!sub.shouldFetchPosts()) {
|
||||
Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 5 days, skipping posts [${sub.lastPost.getNowDiffDays()} days ago]");
|
||||
toQuery.remove(ResultCapabilities.TYPE_POSTS);
|
||||
}
|
||||
}
|
||||
return@map results;
|
||||
}
|
||||
.asSequence()
|
||||
.toList();
|
||||
|
||||
val pager = MultiChronoContentPager(pagers.toTypedArray());
|
||||
pager.initialize();
|
||||
pagerResult = pager;
|
||||
//Merged pager
|
||||
val pagers = toQuery
|
||||
.parallelStream()
|
||||
.map {
|
||||
val results = client.getChannelContents(channelUrl, it, ResultCapabilities.ORDER_CHONOLOGICAL) ;
|
||||
|
||||
when(it) {
|
||||
ResultCapabilities.TYPE_STREAMS -> {
|
||||
val streamResults = results.getResults();
|
||||
if(streamResults.size == 0)
|
||||
lastStream = OffsetDateTime.MIN;
|
||||
else
|
||||
lastStream = results.getResults().firstOrNull()?.datetime;
|
||||
}
|
||||
}
|
||||
return@map results;
|
||||
}
|
||||
.asSequence()
|
||||
.toList();
|
||||
|
||||
val pager = MultiChronoContentPager(pagers.toTypedArray());
|
||||
pager.initialize();
|
||||
pagerResult = pager;
|
||||
}
|
||||
else {
|
||||
pagerResult = client.getChannelContents(channelUrl, ResultCapabilities.TYPE_MIXED, ResultCapabilities.ORDER_CHONOLOGICAL);
|
||||
}
|
||||
} else {
|
||||
pagerResult = if (type == ResultCapabilities.TYPE_SHORTS && clientCapabilities.hasType(ResultCapabilities.TYPE_SHORTS)) {
|
||||
client.getChannelContents(channelUrl, ResultCapabilities.TYPE_SHORTS, ResultCapabilities.ORDER_CHONOLOGICAL);
|
||||
} else {
|
||||
EmptyPager()
|
||||
}
|
||||
}
|
||||
else
|
||||
pagerResult = client.getChannelContents(channelUrl, ResultCapabilities.TYPE_MIXED, ResultCapabilities.ORDER_CHONOLOGICAL);
|
||||
|
||||
//Subscription optimization
|
||||
val sub = StateSubscriptions.instance.getSubscription(channelUrl);
|
||||
@@ -838,10 +858,10 @@ class StatePlatform {
|
||||
|
||||
return pagerResult;
|
||||
}
|
||||
fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List<String>? = null): IPager<IPlatformContent> {
|
||||
fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List<String>? = null, type: String? = null): IPager<IPlatformContent> {
|
||||
Logger.i(TAG, "Platform - getChannelVideos");
|
||||
val baseClient = getChannelClient(channelUrl, ignorePlugins);
|
||||
return getChannelContent(baseClient, channelUrl, isSubscriptionOptimized, usePooledClients);
|
||||
return getChannelContent(baseClient, channelUrl, isSubscriptionOptimized, usePooledClients, type);
|
||||
}
|
||||
fun getChannelContent(channelUrl: String, type: String?, ordering: String = ResultCapabilities.ORDER_CHONOLOGICAL): IPager<IPlatformContent> {
|
||||
val client = getChannelClient(channelUrl);
|
||||
@@ -893,9 +913,9 @@ class StatePlatform {
|
||||
return urls;
|
||||
}
|
||||
|
||||
fun hasEnabledPlaylistClient(url: String) : Boolean = getEnabledClients().any { it.isPlaylistUrl(url) };
|
||||
fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { it.isPlaylistUrl(url) }
|
||||
fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { it.isPlaylistUrl(url) }
|
||||
fun hasEnabledPlaylistClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) };
|
||||
fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) }
|
||||
fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) }
|
||||
?: throw NoPlatformClientException("No client enabled that supports this playlist url (${url})");
|
||||
fun getPlaylist(url: String): IPlatformPlaylistDetails {
|
||||
return getPlaylistClient(url).getPlaylist(url);
|
||||
|
||||
@@ -598,7 +598,7 @@ class StatePlayer {
|
||||
}
|
||||
|
||||
if(_queuePosition < _queue.size) {
|
||||
return _queue[_queuePosition];
|
||||
return getCurrentQueueItem();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.futo.platformplayer.exceptions.ReconstructionException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImportCache
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.sToOffsetDateTimeUTC
|
||||
import com.futo.platformplayer.smartMerge
|
||||
import com.futo.platformplayer.states.StateSubscriptionGroups.Companion
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
@@ -85,7 +86,7 @@ class StatePlaylists {
|
||||
if(value.isEmpty())
|
||||
return OffsetDateTime.MIN;
|
||||
val tryParse = value.toLongOrNull() ?: 0;
|
||||
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(tryParse), ZoneOffset.UTC);
|
||||
return tryParse.sToOffsetDateTimeUTC();
|
||||
}
|
||||
private fun setWatchLaterReorderTime() {
|
||||
val now = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
@@ -400,12 +401,15 @@ class StatePlaylists {
|
||||
companion object {
|
||||
val TAG = "StatePlaylists";
|
||||
private var _instance : StatePlaylists? = null;
|
||||
private var _lockObject = Object()
|
||||
val instance : StatePlaylists
|
||||
get(){
|
||||
if(_instance == null)
|
||||
_instance = StatePlaylists();
|
||||
return _instance!!;
|
||||
};
|
||||
get() {
|
||||
synchronized(_lockObject) {
|
||||
if (_instance == null)
|
||||
_instance = StatePlaylists();
|
||||
return _instance!!;
|
||||
}
|
||||
}
|
||||
|
||||
fun finish() {
|
||||
_instance?.let {
|
||||
@@ -419,17 +423,25 @@ class StatePlaylists {
|
||||
class PlaylistBackup: ReconstructStore<Playlist>() {
|
||||
override fun toReconstruction(obj: Playlist): String {
|
||||
val items = ArrayList<String>();
|
||||
items.add(obj.name);
|
||||
items.add(obj.name + ":::" + obj.id);
|
||||
items.addAll(obj.videos.map { it.url });
|
||||
return items.map { it.replace("\n","") }.joinToString("\n");
|
||||
}
|
||||
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder, importCache: ImportCache?): Playlist {
|
||||
var idToUse = id;
|
||||
val items = backup.split("\n");
|
||||
if(items.size <= 0) {
|
||||
throw IllegalStateException("Cannot reconstructor playlist ${id}");
|
||||
}
|
||||
|
||||
val name = items[0];
|
||||
var name = items[0];
|
||||
if(name.contains(":::")){
|
||||
val splitIndex = name.indexOf(":::");
|
||||
val foundId = name.substring(splitIndex + 3);
|
||||
if(!foundId.isNullOrEmpty())
|
||||
idToUse = foundId;
|
||||
name = name.substring(0, splitIndex);
|
||||
}
|
||||
val videos = items.drop(1).filter { it.isNotEmpty() }.map {
|
||||
try {
|
||||
val videoUrl = it;
|
||||
@@ -461,7 +473,7 @@ class StatePlaylists {
|
||||
throw ReconstructionException(name, "${name}:[${it}] ${ex.message}", ex);
|
||||
}
|
||||
}.filter { it != null }.map { it!! }
|
||||
return Playlist(id, name, videos);
|
||||
return Playlist(idToUse, name, videos);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,7 +236,7 @@ class StatePolycentric {
|
||||
return Pair(didUpdate, listOf(url));
|
||||
}
|
||||
|
||||
fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager<IPlatformContent>? {
|
||||
fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1, type: String? = null): IPager<IPlatformContent>? {
|
||||
ensureEnabled()
|
||||
|
||||
//TODO: Currently abusing subscription concurrency for parallelism
|
||||
@@ -248,7 +248,11 @@ class StatePolycentric {
|
||||
|
||||
return@mapNotNull Pair(client, scope.async(Dispatchers.IO) {
|
||||
try {
|
||||
return@async StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency);
|
||||
if (type == null) {
|
||||
return@async StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency);
|
||||
} else {
|
||||
return@async StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency, type = type);
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e(TAG, "getChannelContent", ex);
|
||||
return@async null;
|
||||
|
||||
@@ -329,8 +329,19 @@ class StateSubscriptions {
|
||||
}
|
||||
}
|
||||
|
||||
if(StateSubscriptionGroups.instance.hasSubscriptionGroup(sub.channel.url))
|
||||
getSubscriptionOtherOrCreate(sub.channel.url, sub.channel.name, sub.channel.thumbnail);
|
||||
if(StateSubscriptionGroups.instance.hasSubscriptionGroup(sub.channel.url)) {
|
||||
val subGroups = StateSubscriptionGroups.instance.getSubscriptionGroups().filter { it.urls.contains(sub.channel.url) };
|
||||
for(group in subGroups) {
|
||||
group.urls.remove(sub.channel.url);
|
||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(group);
|
||||
}
|
||||
/*
|
||||
getSubscriptionOtherOrCreate(
|
||||
sub.channel.url,
|
||||
sub.channel.name,
|
||||
sub.channel.thumbnail
|
||||
); */
|
||||
}
|
||||
}
|
||||
return sub;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
package com.futo.platformplayer.stores
|
||||
|
||||
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.ManagedStore
|
||||
import com.futo.platformplayer.stores.v2.StoreSerializer
|
||||
|
||||
@@ -274,10 +274,17 @@ class ManagedDBStore<I: ManagedDBIndex<T>, T, D: ManagedDBDatabase<T, I, DA>, DA
|
||||
val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} LIKE ? ${_orderSQL} LIMIT ? OFFSET ?";
|
||||
val query = SimpleSQLiteQuery(queryStr, arrayOf(obj, pageSize, page * pageSize));
|
||||
return deserializeIndexes(dbDaoBase.getMultiple(query));
|
||||
}fun queryLike2Page(field: String, field2: String, obj: String, page: Int, pageSize: Int): List<I> {
|
||||
val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} LIKE ? OR ${field2} LIKE ? ${_orderSQL} LIMIT ? OFFSET ?";
|
||||
val query = SimpleSQLiteQuery(queryStr, arrayOf(obj, obj, pageSize, page * pageSize));
|
||||
return deserializeIndexes(dbDaoBase.getMultiple(query));
|
||||
}
|
||||
fun queryLikeObjectPage(field: String, obj: String, page: Int, pageSize: Int): List<T> {
|
||||
return convertObjects(queryLikePage(field, obj, page, pageSize));
|
||||
}
|
||||
fun queryLike2ObjectPage(field: String, field2: String, obj: String, page: Int, pageSize: Int): List<T> {
|
||||
return convertObjects(queryLike2Page(field, field2, obj, page, pageSize));
|
||||
}
|
||||
|
||||
|
||||
//Query Page Objects
|
||||
@@ -336,6 +343,13 @@ class ManagedDBStore<I: ManagedDBIndex<T>, T, D: ManagedDBDatabase<T, I, DA>, DA
|
||||
queryLikePage(field, obj, it - 1, pageSize);
|
||||
});
|
||||
}
|
||||
fun queryLike2Pager(field: KProperty<*>, field2: KProperty<*>, obj: String, pageSize: Int): IPager<I> = queryLike2Pager(validateFieldName(field), validateFieldName(field2), obj, pageSize);
|
||||
fun queryLike2Pager(field: String, field2: String, obj: String, pageSize: Int): IPager<I> {
|
||||
return AdhocPager({
|
||||
Logger.i("ManagedDBStore", "Next Page [query: ${obj}](${it}) ${pageSize}");
|
||||
queryLike2Page(field, field2, obj, it - 1, pageSize);
|
||||
});
|
||||
}
|
||||
fun queryLikeObjectPager(field: KProperty<*>, obj: String, pageSize: Int): IPager<T> = queryLikeObjectPager(validateFieldName(field), obj, pageSize);
|
||||
fun queryLikeObjectPager(field: String, obj: String, pageSize: Int): IPager<T> {
|
||||
return AdhocPager({
|
||||
@@ -344,6 +358,14 @@ class ManagedDBStore<I: ManagedDBIndex<T>, T, D: ManagedDBDatabase<T, I, DA>, DA
|
||||
});
|
||||
}
|
||||
|
||||
fun queryLike2ObjectPager(field: KProperty<*>, field2: KProperty<*>, obj: String, pageSize: Int): IPager<T> = queryLike2ObjectPager(validateFieldName(field), validateFieldName(field2), obj, pageSize);
|
||||
fun queryLike2ObjectPager(field: String, field2: String, obj: String, pageSize: Int): IPager<T> {
|
||||
return AdhocPager({
|
||||
Logger.i("ManagedDBStore", "Next Page [query: ${obj}](${it}) ${pageSize}");
|
||||
queryLike2ObjectPage(field, field2, obj, it - 1, pageSize);
|
||||
});
|
||||
}
|
||||
|
||||
//Query Pager with convert
|
||||
fun <X> queryPager(field: KProperty<*>, obj: Any, pageSize: Int, convert: (I)->X): IPager<X> = queryPager(validateFieldName(field), obj, pageSize, convert);
|
||||
fun <X> queryPager(field: String, obj: Any, pageSize: Int, convert: (I)->X): IPager<X> {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.futo.platformplayer.sync.internal
|
||||
|
||||
import com.futo.platformplayer.ensureNotMainThread
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.noise.protocol.CipherStatePair
|
||||
import com.futo.platformplayer.noise.protocol.DHState
|
||||
import com.futo.platformplayer.noise.protocol.HandshakeState
|
||||
import com.futo.platformplayer.states.StateSync
|
||||
import com.futo.polycentric.core.base64ToByteArray
|
||||
import com.futo.polycentric.core.toBase64
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
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?) {
|
||||
ensureNotMainThread()
|
||||
if (data != null) {
|
||||
session.send(opcode, subOpcode, data, contentEncoding)
|
||||
} else {
|
||||
@@ -69,12 +73,12 @@ class ChannelRelayed(
|
||||
private val sendLock = Object()
|
||||
private val decryptLock = Object()
|
||||
private var handshakeState: HandshakeState? = if (initiator) {
|
||||
HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR).apply {
|
||||
HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR).apply {
|
||||
localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair)
|
||||
remotePublicKey.setPublicKey(Base64.getDecoder().decode(publicKey), 0)
|
||||
remotePublicKey.setPublicKey(publicKey.base64ToByteArray(), 0)
|
||||
}
|
||||
} else {
|
||||
HandshakeState(StateSync.protocolName, HandshakeState.RESPONDER).apply {
|
||||
HandshakeState(SyncService.protocolName, HandshakeState.RESPONDER).apply {
|
||||
localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair)
|
||||
}
|
||||
}
|
||||
@@ -82,7 +86,7 @@ class ChannelRelayed(
|
||||
override var authorizable: IAuthorizable? = null
|
||||
val isAuthorized: Boolean get() = authorizable?.isAuthorized ?: false
|
||||
var connectionId: Long = 0L
|
||||
override var remotePublicKey: String? = publicKey
|
||||
override var remotePublicKey: String? = publicKey.base64ToByteArray().toBase64()
|
||||
private set
|
||||
override var remoteVersion: Int? = null
|
||||
private set
|
||||
@@ -92,11 +96,39 @@ class ChannelRelayed(
|
||||
private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null
|
||||
private var onClose: ((IChannel) -> Unit)? = null
|
||||
private var disposed = false
|
||||
private var _lastPongTime: Long = 0
|
||||
private val _pingInterval: Long = 5000 // 5 seconds in milliseconds
|
||||
private val _disconnectTimeout: Long = 30000 // 30 seconds in milliseconds
|
||||
|
||||
init {
|
||||
handshakeState?.start()
|
||||
}
|
||||
|
||||
private fun startPingLoop() {
|
||||
if (remoteVersion!! < 5) {
|
||||
return
|
||||
}
|
||||
|
||||
_lastPongTime = System.currentTimeMillis()
|
||||
|
||||
Thread {
|
||||
try {
|
||||
while (!disposed) {
|
||||
Thread.sleep(_pingInterval)
|
||||
if (System.currentTimeMillis() - _lastPongTime > _disconnectTimeout) {
|
||||
Logger.e("ChannelRelayed", "Channel timed out waiting for PONG; closing.")
|
||||
close()
|
||||
break
|
||||
}
|
||||
send(Opcode.PING.value, 0u)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("ChannelRelayed", "Ping loop failed", e)
|
||||
close()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
override fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) {
|
||||
this.onData = onData
|
||||
}
|
||||
@@ -132,6 +164,10 @@ class ChannelRelayed(
|
||||
}
|
||||
|
||||
fun invokeDataHandler(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
|
||||
if (opcode == Opcode.PONG.value) {
|
||||
_lastPongTime = System.currentTimeMillis()
|
||||
return
|
||||
}
|
||||
onData?.invoke(session, this, opcode, subOpcode, data)
|
||||
}
|
||||
|
||||
@@ -141,15 +177,17 @@ class ChannelRelayed(
|
||||
this.remoteVersion = remoteVersion
|
||||
val remoteKeyBytes = ByteArray(handshakeState!!.remotePublicKey.publicKeyLength)
|
||||
handshakeState!!.remotePublicKey.getPublicKey(remoteKeyBytes, 0)
|
||||
this.remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes)
|
||||
this.remotePublicKey = remoteKeyBytes.toBase64()
|
||||
handshakeState?.destroy()
|
||||
handshakeState = null
|
||||
this.transport = transport
|
||||
Logger.i("ChannelRelayed", "Completed handshake for connectionId $connectionId")
|
||||
startPingLoop()
|
||||
}
|
||||
|
||||
private fun sendPacket(packet: ByteArray) {
|
||||
throwIfDisposed()
|
||||
ensureNotMainThread()
|
||||
|
||||
synchronized(sendLock) {
|
||||
val encryptedPayload = ByteArray(packet.size + 16)
|
||||
@@ -167,6 +205,7 @@ class ChannelRelayed(
|
||||
|
||||
fun sendError(errorCode: SyncErrorCode) {
|
||||
throwIfDisposed()
|
||||
ensureNotMainThread()
|
||||
|
||||
synchronized(sendLock) {
|
||||
val packet = ByteArray(4)
|
||||
@@ -187,6 +226,7 @@ class ChannelRelayed(
|
||||
|
||||
override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, ce: ContentEncoding?) {
|
||||
throwIfDisposed()
|
||||
ensureNotMainThread()
|
||||
|
||||
var contentEncoding: ContentEncoding? = ce
|
||||
var processedData = data
|
||||
@@ -270,12 +310,13 @@ class ChannelRelayed(
|
||||
|
||||
fun sendRequestTransport(requestId: Int, publicKey: String, appId: UInt, pairingCode: String? = null) {
|
||||
throwIfDisposed()
|
||||
ensureNotMainThread()
|
||||
|
||||
synchronized(sendLock) {
|
||||
val channelMessage = ByteArray(1024)
|
||||
val channelBytesWritten = handshakeState!!.writeMessage(channelMessage, 0, null, 0, 0)
|
||||
|
||||
val publicKeyBytes = Base64.getDecoder().decode(publicKey)
|
||||
val publicKeyBytes = publicKey.base64ToByteArray()
|
||||
if (publicKeyBytes.size != 32) throw IllegalArgumentException("Public key must be 32 bytes")
|
||||
|
||||
val (pairingMessageLength, pairingMessage) = if (pairingCode != null) {
|
||||
@@ -310,6 +351,7 @@ class ChannelRelayed(
|
||||
|
||||
fun sendResponseTransport(remoteVersion: Int, requestId: Int, handshakeMessage: ByteArray) {
|
||||
throwIfDisposed()
|
||||
ensureNotMainThread()
|
||||
|
||||
synchronized(sendLock) {
|
||||
val message = ByteArray(1024)
|
||||
|
||||
@@ -0,0 +1,836 @@
|
||||
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.base64UrlToByteArray
|
||||
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 = urlSafePkey.base64UrlToByteArray().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 = urlSafePkey.base64UrlToByteArray().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 = urlSafePkey.base64UrlToByteArray().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) || getLinkType(pkey) == LinkType.Direct) 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 connectedDirectly = getLinkType(it) == LinkType.Direct
|
||||
if (connectedDirectly) {
|
||||
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 {
|
||||
if (Settings.instance.synchronization.connectLocalDirectThroughRelay) {
|
||||
getLinkType(it) != LinkType.Direct
|
||||
} else {
|
||||
!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 (getLinkType(targetKey) != LinkType.Direct && 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 (!isConnected(targetKey) && 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 getLinkType(publicKey: String): LinkType = synchronized(_sessions) { _sessions[publicKey]?.linkType ?: LinkType.None }
|
||||
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.toList()
|
||||
}.forEach { it.close() }
|
||||
|
||||
synchronized(_sessions) {
|
||||
_sessions.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDeviceName(): String {
|
||||
val manufacturer = Build.MANUFACTURER.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
|
||||
val model = Build.MODEL
|
||||
|
||||
return if (model.startsWith(manufacturer, ignoreCase = true)) {
|
||||
model.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
|
||||
} else {
|
||||
"$manufacturer $model".replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val dh = "25519"
|
||||
val pattern = "IK"
|
||||
val cipher = "ChaChaPoly"
|
||||
val hash = "BLAKE2b"
|
||||
var protocolName = "Noise_${pattern}_${dh}_${cipher}_${hash}"
|
||||
|
||||
private const val TAG = "SyncService"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.futo.platformplayer.sync.internal
|
||||
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.ensureNotMainThread
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
@@ -16,6 +17,8 @@ interface IAuthorizable {
|
||||
|
||||
class SyncSession : IAuthorizable {
|
||||
private val _channels: MutableList<IChannel> = mutableListOf()
|
||||
@Volatile
|
||||
private var _snapshot: Array<IChannel> = emptyArray()
|
||||
private var _authorized: Boolean = false
|
||||
private var _remoteAuthorized: Boolean = false
|
||||
private val _onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit
|
||||
@@ -82,6 +85,8 @@ class SyncSession : IAuthorizable {
|
||||
|
||||
synchronized(_channels) {
|
||||
_channels.add(channel)
|
||||
_channels.sortBy { it.linkType.ordinal }
|
||||
_snapshot = _channels.toTypedArray()
|
||||
connected = _channels.isNotEmpty()
|
||||
}
|
||||
|
||||
@@ -123,15 +128,20 @@ class SyncSession : IAuthorizable {
|
||||
fun removeChannel(channel: IChannel) {
|
||||
synchronized(_channels) {
|
||||
_channels.remove(channel)
|
||||
_snapshot = _channels.toTypedArray()
|
||||
connected = _channels.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
synchronized(_channels) {
|
||||
_channels.toTypedArray()
|
||||
}.forEach { it.close() }
|
||||
|
||||
val toClose = synchronized(_channels) {
|
||||
val arr = _channels.toTypedArray()
|
||||
_channels.clear()
|
||||
_snapshot = emptyArray()
|
||||
connected = false
|
||||
arr
|
||||
}
|
||||
toClose.forEach { it.close() }
|
||||
_onClose(this)
|
||||
}
|
||||
|
||||
@@ -192,22 +202,25 @@ class SyncSession : IAuthorizable {
|
||||
}
|
||||
|
||||
inline fun <reified T> sendJsonData(subOpcode: UByte, data: T) {
|
||||
ensureNotMainThread()
|
||||
send(Opcode.DATA.value, subOpcode, Json.encodeToString(data))
|
||||
}
|
||||
|
||||
fun sendData(subOpcode: UByte, data: String) {
|
||||
ensureNotMainThread()
|
||||
send(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip)
|
||||
}
|
||||
|
||||
fun send(opcode: UByte, subOpcode: UByte, data: String) {
|
||||
ensureNotMainThread()
|
||||
send(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip)
|
||||
}
|
||||
|
||||
fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer? = null, contentEncoding: ContentEncoding? = null) {
|
||||
val channels = synchronized(_channels) { _channels.sortedBy { it.linkType.ordinal }.toList() }
|
||||
ensureNotMainThread()
|
||||
val channels = _snapshot
|
||||
if (channels.isEmpty()) {
|
||||
//TODO: Should this throw?
|
||||
Logger.v(TAG, "Packet was not sent (opcode = $opcode, subOpcode = $subOpcode) due to no connected sockets")
|
||||
Logger.v(TAG, "Packet was not sent … no connected sockets")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
package com.futo.platformplayer.sync.internal
|
||||
|
||||
import android.os.Build
|
||||
import com.futo.platformplayer.LittleEndianDataInputStream
|
||||
import com.futo.platformplayer.LittleEndianDataOutputStream
|
||||
import com.futo.platformplayer.ensureNotMainThread
|
||||
import com.futo.platformplayer.findCandidateAddresses
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.noise.protocol.CipherStatePair
|
||||
import com.futo.platformplayer.noise.protocol.DHState
|
||||
import com.futo.platformplayer.noise.protocol.HandshakeState
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateSync
|
||||
import com.futo.platformplayer.sync.internal.ChannelRelayed.Companion
|
||||
import com.futo.polycentric.core.base64ToByteArray
|
||||
import com.futo.polycentric.core.toBase64
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
@@ -24,10 +29,9 @@ import java.nio.ByteOrder
|
||||
import java.util.Base64
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.zip.GZIPInputStream
|
||||
import java.util.zip.GZIPOutputStream
|
||||
import kotlin.math.min
|
||||
import kotlin.system.measureTimeMillis
|
||||
import kotlin.time.measureTime
|
||||
|
||||
class SyncSocketSession {
|
||||
private val _socket: Socket
|
||||
@@ -75,6 +79,11 @@ class SyncSocketSession {
|
||||
private val _pendingBulkGetRecordRequests = ConcurrentHashMap<Int, CompletableDeferred<Map<String, Pair<ByteArray, Long>>>>()
|
||||
private val _pendingBulkConnectionInfoRequests = ConcurrentHashMap<Int, CompletableDeferred<Map<String, ConnectionInfo>>>()
|
||||
|
||||
@Volatile
|
||||
private var _lastPongTime: Long = System.currentTimeMillis()
|
||||
private val _pingInterval: Long = 5000 // 5 seconds in milliseconds
|
||||
private val _disconnectTimeout: Long = 30000 // 30 seconds in milliseconds
|
||||
|
||||
data class ConnectionInfo(
|
||||
val port: UShort,
|
||||
val name: String,
|
||||
@@ -115,7 +124,7 @@ class SyncSocketSession {
|
||||
|
||||
val localPublicKey = ByteArray(localKeyPair.publicKeyLength)
|
||||
localKeyPair.getPublicKey(localPublicKey, 0)
|
||||
_localPublicKey = Base64.getEncoder().encodeToString(localPublicKey)
|
||||
_localPublicKey = localPublicKey.toBase64()
|
||||
}
|
||||
|
||||
fun startAsInitiator(remotePublicKey: String, appId: UInt = 0u, pairingCode: String? = null) {
|
||||
@@ -124,6 +133,7 @@ class SyncSocketSession {
|
||||
try {
|
||||
handshakeAsInitiator(remotePublicKey, appId, pairingCode)
|
||||
_onHandshakeComplete?.invoke(this)
|
||||
startPingLoop()
|
||||
receiveLoop()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to run as initiator", e)
|
||||
@@ -138,6 +148,7 @@ class SyncSocketSession {
|
||||
try {
|
||||
handshakeAsInitiator(remotePublicKey, appId, pairingCode)
|
||||
_onHandshakeComplete?.invoke(this)
|
||||
startPingLoop()
|
||||
receiveLoop()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to run as initiator", e)
|
||||
@@ -152,6 +163,7 @@ class SyncSocketSession {
|
||||
try {
|
||||
if (handshakeAsResponder()) {
|
||||
_onHandshakeComplete?.invoke(this)
|
||||
startPingLoop()
|
||||
receiveLoop()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -166,7 +178,7 @@ class SyncSocketSession {
|
||||
var totalBytesReceived: Int = 0
|
||||
while (totalBytesReceived < size) {
|
||||
val bytesReceived = _inputStream.read(buffer, offset + totalBytesReceived, size - totalBytesReceived)
|
||||
if (bytesReceived == 0)
|
||||
if (bytesReceived <= 0)
|
||||
throw Exception("Socket disconnected")
|
||||
totalBytesReceived += bytesReceived
|
||||
}
|
||||
@@ -240,16 +252,16 @@ class SyncSocketSession {
|
||||
private fun handshakeAsInitiator(remotePublicKey: String, appId: UInt, pairingCode: String?) {
|
||||
performVersionCheck()
|
||||
|
||||
val initiator = HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR)
|
||||
val initiator = HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR)
|
||||
initiator.localKeyPair.copyFrom(_localKeyPair)
|
||||
initiator.remotePublicKey.setPublicKey(Base64.getDecoder().decode(remotePublicKey), 0)
|
||||
initiator.remotePublicKey.setPublicKey(remotePublicKey.base64ToByteArray(), 0)
|
||||
initiator.start()
|
||||
|
||||
val pairingMessage: ByteArray
|
||||
val pairingMessageLength: Int
|
||||
if (pairingCode != null) {
|
||||
val pairingHandshake = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR)
|
||||
pairingHandshake.remotePublicKey.setPublicKey(Base64.getDecoder().decode(remotePublicKey), 0)
|
||||
pairingHandshake.remotePublicKey.setPublicKey(remotePublicKey.base64ToByteArray(), 0)
|
||||
pairingHandshake.start()
|
||||
val pairingCodeBytes = pairingCode.toByteArray(Charsets.UTF_8)
|
||||
val pairingBuffer = ByteArray(512)
|
||||
@@ -288,13 +300,13 @@ class SyncSocketSession {
|
||||
_cipherStatePair = initiator.split()
|
||||
val remoteKeyBytes = ByteArray(initiator.remotePublicKey.publicKeyLength)
|
||||
initiator.remotePublicKey.getPublicKey(remoteKeyBytes, 0)
|
||||
_remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes)
|
||||
_remotePublicKey = remoteKeyBytes.toBase64()
|
||||
}
|
||||
|
||||
private fun handshakeAsResponder(): Boolean {
|
||||
performVersionCheck()
|
||||
|
||||
val responder = HandshakeState(StateSync.protocolName, HandshakeState.RESPONDER)
|
||||
val responder = HandshakeState(SyncService.protocolName, HandshakeState.RESPONDER)
|
||||
responder.localKeyPair.copyFrom(_localKeyPair)
|
||||
responder.start()
|
||||
|
||||
@@ -311,7 +323,7 @@ class SyncSocketSession {
|
||||
val appId = messageBuffer.int.toUInt()
|
||||
val pairingMessageLength = messageBuffer.int
|
||||
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) }
|
||||
|
||||
var pairingCode: String? = null
|
||||
@@ -328,7 +340,7 @@ class SyncSocketSession {
|
||||
responder.readMessage(mainMessage, 0, mainLength, plaintext, 0)
|
||||
val remoteKeyBytes = ByteArray(responder.remotePublicKey.publicKeyLength)
|
||||
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)
|
||||
if (!isAllowedToConnect) {
|
||||
@@ -342,12 +354,12 @@ class SyncSocketSession {
|
||||
_outputStream.write(responseBuffer, 0, 4 + responseLength)
|
||||
|
||||
_cipherStatePair = responder.split()
|
||||
_remotePublicKey = remotePublicKey
|
||||
_remotePublicKey = remotePublicKey.base64ToByteArray().toBase64()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun performVersionCheck() {
|
||||
val CURRENT_VERSION = 4
|
||||
val CURRENT_VERSION = 5
|
||||
val MINIMUM_VERSION = 4
|
||||
|
||||
val versionBytes = ByteArray(4)
|
||||
@@ -437,7 +449,7 @@ class SyncSocketSession {
|
||||
ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(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 +459,14 @@ class SyncSocketSession {
|
||||
ensureNotMainThread()
|
||||
|
||||
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()[5] = subOpcode
|
||||
_sendBuffer.asUByteArray()[6] = ContentEncoding.Raw.value
|
||||
|
||||
//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})")
|
||||
|
||||
ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len)
|
||||
@@ -505,7 +517,7 @@ class SyncSocketSession {
|
||||
return
|
||||
}
|
||||
val channelHandshakeMessage = ByteArray(channelMessageLength).also { data.get(it) }
|
||||
val publicKey = Base64.getEncoder().encodeToString(publicKeyBytes)
|
||||
val publicKey = publicKeyBytes.toBase64()
|
||||
val pairingCode = if (pairingMessageLength > 0) {
|
||||
val pairingProtocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.RESPONDER).apply {
|
||||
localKeyPair.copyFrom(_localKeyPair)
|
||||
@@ -518,7 +530,7 @@ class SyncSocketSession {
|
||||
val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode, appId) ?: true)
|
||||
if (!isAllowed) {
|
||||
val rp = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN)
|
||||
rp.putInt(2) // Status code for not allowed
|
||||
rp.putInt(7) // Status code for not allowed
|
||||
rp.putLong(connectionId)
|
||||
rp.putInt(requestId)
|
||||
rp.rewind()
|
||||
@@ -660,7 +672,7 @@ class SyncSocketSession {
|
||||
val records = mutableMapOf<String, Pair<ByteArray, Long>>()
|
||||
repeat(recordCount) {
|
||||
val publisherBytes = ByteArray(32).also { data.get(it) }
|
||||
val publisher = Base64.getEncoder().encodeToString(publisherBytes)
|
||||
val publisher = publisherBytes.toBase64()
|
||||
val blobLength = data.int
|
||||
val encryptedBlob = ByteArray(blobLength).also { data.get(it) }
|
||||
val timestamp = data.long
|
||||
@@ -701,7 +713,7 @@ class SyncSocketSession {
|
||||
val numResponses = data.get().toInt()
|
||||
val result = mutableMapOf<String, ConnectionInfo>()
|
||||
repeat(numResponses) {
|
||||
val publicKey = Base64.getEncoder().encodeToString(ByteArray(32).also { data.get(it) })
|
||||
val publicKey = ByteArray(32).also { data.get(it) }.toBase64()
|
||||
val status = data.get().toInt()
|
||||
if (status == 0) {
|
||||
val infoSize = data.int
|
||||
@@ -802,7 +814,7 @@ class SyncSocketSession {
|
||||
return
|
||||
}
|
||||
val decryptedPayload = channel.decrypt(data)
|
||||
val errorCode = SyncErrorCode.entries.find { it.value == decryptedPayload.int } ?: SyncErrorCode.ConnectionClosed
|
||||
val errorCode = decryptedPayload.int
|
||||
Logger.e(TAG, "Received relayed error (errorCode = $errorCode) on connectionId $connectionId, closing")
|
||||
channel.close()
|
||||
_channels.remove(connectionId)
|
||||
@@ -813,7 +825,7 @@ class SyncSocketSession {
|
||||
return
|
||||
}
|
||||
val connectionId = data.long
|
||||
val errorCode = SyncErrorCode.entries.find { it.value == data.int } ?: SyncErrorCode.ConnectionClosed
|
||||
val errorCode = data.int
|
||||
val channel = _channels[connectionId] ?: run {
|
||||
Logger.e(TAG, "Received error code $errorCode for non-existent channel with connectionId $connectionId")
|
||||
return
|
||||
@@ -828,6 +840,30 @@ class SyncSocketSession {
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPingLoop() {
|
||||
if (remoteVersion < 5) return
|
||||
|
||||
_lastPongTime = System.currentTimeMillis()
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
while (_started) {
|
||||
delay(_pingInterval)
|
||||
|
||||
if (System.currentTimeMillis() - _lastPongTime > _disconnectTimeout) {
|
||||
Logger.e(TAG, "Session timed out waiting for PONG; closing.")
|
||||
stop()
|
||||
break
|
||||
}
|
||||
send(Opcode.PING.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Ping loop failed", e)
|
||||
stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePacket(opcode: UByte, subOpcode: UByte, d: ByteBuffer, contentEncoding: UByte, sourceChannel: ChannelRelayed?) {
|
||||
Logger.i(TAG, "Handle packet (opcode = ${opcode}, subOpcode = ${subOpcode})")
|
||||
|
||||
@@ -837,12 +873,16 @@ class SyncSocketSession {
|
||||
if (!isGzipSupported)
|
||||
throw Exception("Failed to handle packet, gzip is not supported for this opcode (opcode = ${opcode}, subOpcode = ${subOpcode}, data.length = ${data.remaining()}).")
|
||||
|
||||
val compressedStream = ByteArrayOutputStream()
|
||||
GZIPOutputStream(compressedStream).use { gzipStream ->
|
||||
gzipStream.write(data.array(), data.position(), data.remaining())
|
||||
gzipStream.finish()
|
||||
val compressedStream = ByteArrayInputStream(data.array(), data.position(), data.remaining())
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
GZIPInputStream(compressedStream).use { gzipStream ->
|
||||
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) {
|
||||
@@ -855,6 +895,11 @@ class SyncSocketSession {
|
||||
return
|
||||
}
|
||||
Opcode.PONG.value -> {
|
||||
if (sourceChannel != null) {
|
||||
sourceChannel.invokeDataHandler(opcode, subOpcode, data)
|
||||
} else {
|
||||
_lastPongTime = System.currentTimeMillis()
|
||||
}
|
||||
Logger.v(TAG, "Received pong")
|
||||
return
|
||||
}
|
||||
@@ -929,7 +974,7 @@ class SyncSocketSession {
|
||||
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 -> {
|
||||
@@ -950,7 +995,7 @@ class SyncSocketSession {
|
||||
val deferred = CompletableDeferred<ConnectionInfo?>()
|
||||
_pendingConnectionInfoRequests[requestId] = deferred
|
||||
try {
|
||||
val publicKeyBytes = Base64.getDecoder().decode(publicKey)
|
||||
val publicKeyBytes = publicKey.base64ToByteArray()
|
||||
if (publicKeyBytes.size != 32) throw IllegalArgumentException("Public key must be 32 bytes")
|
||||
val packet = ByteBuffer.allocate(4 + 32).order(ByteOrder.LITTLE_ENDIAN)
|
||||
packet.putInt(requestId)
|
||||
@@ -973,7 +1018,7 @@ class SyncSocketSession {
|
||||
packet.putInt(requestId)
|
||||
packet.put(publicKeys.size.toByte())
|
||||
for (pk in publicKeys) {
|
||||
val pkBytes = Base64.getDecoder().decode(pk)
|
||||
val pkBytes = pk.base64ToByteArray()
|
||||
if (pkBytes.size != 32) throw IllegalArgumentException("Invalid public key length for $pk")
|
||||
packet.put(pkBytes)
|
||||
}
|
||||
@@ -989,7 +1034,7 @@ class SyncSocketSession {
|
||||
suspend fun startRelayedChannel(publicKey: String, appId: UInt = 0u, pairingCode: String? = null): ChannelRelayed? {
|
||||
val requestId = generateRequestId()
|
||||
val deferred = CompletableDeferred<ChannelRelayed>()
|
||||
val channel = ChannelRelayed(this, _localKeyPair, publicKey, true)
|
||||
val channel = ChannelRelayed(this, _localKeyPair, publicKey.base64ToByteArray().toBase64(), true)
|
||||
_onNewChannel?.invoke(this, channel)
|
||||
_pendingChannels[requestId] = channel to deferred
|
||||
try {
|
||||
@@ -1034,20 +1079,9 @@ class SyncSocketSession {
|
||||
) {
|
||||
if (authorizedKeys.size > 255) throw IllegalArgumentException("Number of authorized keys exceeds 255")
|
||||
|
||||
val ipv4Addresses = mutableListOf<String>()
|
||||
val ipv6Addresses = mutableListOf<String>()
|
||||
for (nic in NetworkInterface.getNetworkInterfaces()) {
|
||||
if (nic.isUp) {
|
||||
for (addr in nic.inetAddresses) {
|
||||
if (!addr.isLoopbackAddress) {
|
||||
when (addr) {
|
||||
is Inet4Address -> ipv4Addresses.add(addr.hostAddress)
|
||||
is Inet6Address -> ipv6Addresses.add(addr.hostAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val candidateAddresses = findCandidateAddresses()
|
||||
val ipv4Addresses = candidateAddresses.filterIsInstance<Inet4Address>()
|
||||
val ipv6Addresses = candidateAddresses.filterIsInstance<Inet6Address>()
|
||||
|
||||
val deviceName = getDeviceName()
|
||||
val nameBytes = getLimitedUtf8Bytes(deviceName, 255)
|
||||
@@ -1059,12 +1093,12 @@ class SyncSocketSession {
|
||||
data.put(nameBytes)
|
||||
data.put(ipv4Addresses.size.toByte())
|
||||
for (addr in ipv4Addresses) {
|
||||
val addrBytes = InetAddress.getByName(addr).address
|
||||
val addrBytes = addr.address
|
||||
data.put(addrBytes)
|
||||
}
|
||||
data.put(ipv6Addresses.size.toByte())
|
||||
for (addr in ipv6Addresses) {
|
||||
val addrBytes = InetAddress.getByName(addr).address
|
||||
val addrBytes = addr.address
|
||||
data.put(addrBytes)
|
||||
}
|
||||
data.put(if (allowLocalDirect) 1 else 0)
|
||||
@@ -1081,7 +1115,7 @@ class SyncSocketSession {
|
||||
publishBytes.put(authorizedKeys.size.toByte())
|
||||
|
||||
for (key in authorizedKeys) {
|
||||
val publicKeyBytes = Base64.getDecoder().decode(key)
|
||||
val publicKeyBytes = key.base64ToByteArray()
|
||||
if (publicKeyBytes.size != 32) throw IllegalArgumentException("Public key must be 32 bytes")
|
||||
|
||||
val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR)
|
||||
@@ -1139,7 +1173,7 @@ class SyncSocketSession {
|
||||
packet.put(consumerPublicKeys.size.toByte())
|
||||
|
||||
for (consumer in consumerPublicKeys) {
|
||||
val consumerBytes = Base64.getDecoder().decode(consumer)
|
||||
val consumerBytes = consumer.base64ToByteArray()
|
||||
if (consumerBytes.size != 32) throw IllegalArgumentException("Consumer public key must be 32 bytes")
|
||||
packet.put(consumerBytes)
|
||||
val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR).apply {
|
||||
@@ -1178,7 +1212,7 @@ class SyncSocketSession {
|
||||
val deferred = CompletableDeferred<Pair<ByteArray, Long>?>()
|
||||
_pendingGetRecordRequests[requestId] = deferred
|
||||
try {
|
||||
val publisherBytes = Base64.getDecoder().decode(publisherPublicKey)
|
||||
val publisherBytes = publisherPublicKey.base64ToByteArray()
|
||||
if (publisherBytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes")
|
||||
val keyBytes = key.toByteArray(Charsets.UTF_8)
|
||||
val packet = ByteBuffer.allocate(4 + 32 + 1 + keyBytes.size).order(ByteOrder.LITTLE_ENDIAN)
|
||||
@@ -1209,7 +1243,7 @@ class SyncSocketSession {
|
||||
packet.put(keyBytes)
|
||||
packet.put(publisherPublicKeys.size.toByte())
|
||||
for (publisher in publisherPublicKeys) {
|
||||
val bytes = Base64.getDecoder().decode(publisher)
|
||||
val bytes = publisher.base64ToByteArray()
|
||||
if (bytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes")
|
||||
packet.put(bytes)
|
||||
}
|
||||
@@ -1228,9 +1262,9 @@ class SyncSocketSession {
|
||||
val deferred = CompletableDeferred<Boolean>()
|
||||
_pendingDeleteRequests[requestId] = deferred
|
||||
try {
|
||||
val publisherBytes = Base64.getDecoder().decode(publisherPublicKey)
|
||||
val publisherBytes = publisherPublicKey.base64ToByteArray()
|
||||
if (publisherBytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes")
|
||||
val consumerBytes = Base64.getDecoder().decode(consumerPublicKey)
|
||||
val consumerBytes = consumerPublicKey.base64ToByteArray()
|
||||
if (consumerBytes.size != 32) throw IllegalArgumentException("Consumer public key must be 32 bytes")
|
||||
val packetSize = 4 + 32 + 32 + 1 + keys.sumOf { 1 + it.toByteArray(Charsets.UTF_8).size }
|
||||
val packet = ByteBuffer.allocate(packetSize).order(ByteOrder.LITTLE_ENDIAN)
|
||||
@@ -1257,9 +1291,9 @@ class SyncSocketSession {
|
||||
val deferred = CompletableDeferred<List<Pair<String, Long>>>()
|
||||
_pendingListKeysRequests[requestId] = deferred
|
||||
try {
|
||||
val publisherBytes = Base64.getDecoder().decode(publisherPublicKey)
|
||||
val publisherBytes = publisherPublicKey.base64ToByteArray()
|
||||
if (publisherBytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes")
|
||||
val consumerBytes = Base64.getDecoder().decode(consumerPublicKey)
|
||||
val consumerBytes = consumerPublicKey.base64ToByteArray()
|
||||
if (consumerBytes.size != 32) throw IllegalArgumentException("Consumer public key must be 32 bytes")
|
||||
val packet = ByteBuffer.allocate(4 + 32 + 32).order(ByteOrder.LITTLE_ENDIAN)
|
||||
packet.putInt(requestId)
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.futo.platformplayer.views
|
||||
import android.content.Context
|
||||
import android.text.TextWatcher
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
@@ -30,9 +32,26 @@ class SearchView : FrameLayout {
|
||||
textSearch = findViewById(R.id.edit_search)
|
||||
buttonClear = findViewById(R.id.button_clear_search)
|
||||
|
||||
buttonClear.setOnClickListener { textSearch.text = "" };
|
||||
buttonClear.setOnClickListener {
|
||||
textSearch.text = ""
|
||||
textSearch?.clearFocus()
|
||||
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(textSearch.windowToken, 0)
|
||||
onSearchChanged.emit("")
|
||||
onEnter.emit("")
|
||||
}
|
||||
textSearch.setOnEditorActionListener { _, i, _ ->
|
||||
if (i == EditorInfo.IME_ACTION_DONE) {
|
||||
textSearch?.clearFocus()
|
||||
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(textSearch.windowToken, 0)
|
||||
onEnter.emit(textSearch.text.toString())
|
||||
return@setOnEditorActionListener true
|
||||
}
|
||||
return@setOnEditorActionListener false
|
||||
|
||||
}
|
||||
textSearch.addTextChangedListener {
|
||||
onSearchChanged.emit(it.toString());
|
||||
buttonClear.visibility = if ((it?.length ?: 0) > 0) View.VISIBLE else View.GONE
|
||||
onSearchChanged.emit(it.toString())
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import androidx.core.view.isVisible
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
|
||||
class DeviceViewHolder : ViewHolder {
|
||||
private val _layoutDevice: FrameLayout;
|
||||
@@ -55,9 +56,17 @@ class DeviceViewHolder : ViewHolder {
|
||||
|
||||
val connect = {
|
||||
device?.let { dev ->
|
||||
StateCasting.instance.activeDevice?.stopCasting();
|
||||
StateCasting.instance.connectDevice(dev);
|
||||
onConnect.emit(dev);
|
||||
if (dev.isReady) {
|
||||
StateCasting.instance.activeDevice?.stopCasting()
|
||||
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;
|
||||
_imageOnline.visibility = if (isOnlineDevice) View.VISIBLE else View.GONE
|
||||
_imageOnline.visibility = if (isOnlineDevice && d.isReady) View.VISIBLE else View.GONE
|
||||
|
||||
if (!d.isReady) {
|
||||
_imageLoader.visibility = View.GONE;
|
||||
|
||||
@@ -14,9 +14,11 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.models.HistoryVideo
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.toHumanNumber
|
||||
import com.futo.platformplayer.toHumanTime
|
||||
import com.futo.platformplayer.views.others.ProgressBar
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
|
||||
class HistoryListViewHolder : ViewHolder {
|
||||
private val _root: ConstraintLayout;
|
||||
@@ -30,6 +32,7 @@ class HistoryListViewHolder : ViewHolder {
|
||||
private val _imageRemove: ImageButton;
|
||||
private val _textHeader: TextView;
|
||||
private val _timeBar: ProgressBar;
|
||||
private val _thumbnailPlatform: PlatformIndicator
|
||||
|
||||
var video: HistoryVideo? = null
|
||||
private set;
|
||||
@@ -47,6 +50,7 @@ class HistoryListViewHolder : ViewHolder {
|
||||
_textVideoDuration = itemView.findViewById(R.id.thumbnail_duration);
|
||||
_containerDuration = itemView.findViewById(R.id.thumbnail_duration_container);
|
||||
_containerLive = itemView.findViewById(R.id.thumbnail_live_container);
|
||||
_thumbnailPlatform = itemView.findViewById(R.id.thumbnail_platform)
|
||||
_imageRemove = itemView.findViewById(R.id.image_trash);
|
||||
_textHeader = itemView.findViewById(R.id.text_header);
|
||||
_timeBar = itemView.findViewById(R.id.time_bar);
|
||||
@@ -73,6 +77,9 @@ class HistoryListViewHolder : ViewHolder {
|
||||
_textAuthor.text = v.video.author.name;
|
||||
_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) {
|
||||
_containerDuration.visibility = View.GONE;
|
||||
_containerLive.visibility = View.VISIBLE;
|
||||
|
||||
@@ -10,10 +10,11 @@ class ItemMoveCallback : ItemTouchHelper.Callback {
|
||||
var onRowMoved = Event2<Int, Int>();
|
||||
var onRowSelected = Event1<ViewHolder>();
|
||||
var onRowClear = Event1<ViewHolder>();
|
||||
var canEdit = true
|
||||
|
||||
constructor() : super() { }
|
||||
|
||||
override fun isLongPressDragEnabled(): Boolean { return true; }
|
||||
override fun isLongPressDragEnabled(): Boolean { return canEdit; }
|
||||
override fun isItemViewSwipeEnabled(): Boolean { return false; }
|
||||
|
||||
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
|
||||
|
||||
@@ -126,7 +126,7 @@ open class PlaylistView : LinearLayout {
|
||||
}
|
||||
else {
|
||||
currentPlaylist = null;
|
||||
_imageThumbnail.setImageResource(0);
|
||||
_imageThumbnail.setImageDrawable(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.futo.platformplayer.views.adapters
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
@@ -17,9 +17,11 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.toHumanNowDiffString
|
||||
import com.futo.platformplayer.toHumanNumber
|
||||
import com.futo.platformplayer.toHumanTime
|
||||
import com.futo.platformplayer.views.others.ProgressBar
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
|
||||
class VideoListEditorViewHolder : ViewHolder {
|
||||
@@ -36,6 +38,7 @@ class VideoListEditorViewHolder : ViewHolder {
|
||||
private val _imageDragDrop: ImageButton;
|
||||
private val _platformIndicator: PlatformIndicator;
|
||||
private val _layoutDownloaded: FrameLayout;
|
||||
private val _timeBar: ProgressBar
|
||||
|
||||
var video: IPlatformVideo? = null
|
||||
private set;
|
||||
@@ -59,6 +62,7 @@ class VideoListEditorViewHolder : ViewHolder {
|
||||
_imageOptions = view.findViewById(R.id.image_settings);
|
||||
_imageDragDrop = view.findViewById<ImageButton>(R.id.image_drag_drop);
|
||||
_platformIndicator = view.findViewById(R.id.thumbnail_platform);
|
||||
_timeBar = view.findViewById(R.id.time_bar);
|
||||
_layoutDownloaded = view.findViewById(R.id.layout_downloaded);
|
||||
|
||||
_imageDragDrop.setOnTouchListener { _, event ->
|
||||
@@ -93,6 +97,9 @@ class VideoListEditorViewHolder : ViewHolder {
|
||||
_textAuthor.text = v.author.name;
|
||||
_textVideoDuration.text = v.duration.toHumanTime(false);
|
||||
|
||||
val historyPosition = StateHistory.instance.getHistoryPosition(v.url)
|
||||
_timeBar.progress = historyPosition.toFloat() / v.duration.toFloat();
|
||||
|
||||
if(v.isLive) {
|
||||
_containerDuration.visibility = View.GONE;
|
||||
_containerLive.visibility = View.VISIBLE;
|
||||
|
||||
+2
@@ -79,6 +79,8 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
||||
return when(contentType) {
|
||||
ContentType.PLACEHOLDER -> createPlaceholderViewHolder(viewGroup);
|
||||
ContentType.MEDIA -> createVideoPreviewViewHolder(viewGroup);
|
||||
ContentType.ARTICLE -> createPostViewHolder(viewGroup);
|
||||
ContentType.WEB -> createPostViewHolder(viewGroup);
|
||||
ContentType.POST -> createPostViewHolder(viewGroup);
|
||||
ContentType.PLAYLIST -> createPlaylistViewHolder(viewGroup);
|
||||
ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup);
|
||||
|
||||
+2
-2
@@ -76,8 +76,8 @@ class PreviewLockedView : LinearLayout {
|
||||
_textLockedUrl.text = content.unlockUrl ?: "";
|
||||
}
|
||||
else {
|
||||
_imageChannelThumbnail.setImageResource(0);
|
||||
_imageVideoThumbnail.setImageResource(0);
|
||||
_imageChannelThumbnail.setImageDrawable(null);
|
||||
_imageVideoThumbnail.setImageDrawable(null);
|
||||
_textLockedDescription.text = "";
|
||||
_textLockedUrl.text = "";
|
||||
}
|
||||
|
||||
+20
-1
@@ -21,9 +21,11 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.dp
|
||||
@@ -141,6 +143,16 @@ class PreviewPostView : LinearLayout {
|
||||
content.content
|
||||
else
|
||||
""
|
||||
} else if(content is IPlatformArticle) {
|
||||
if(!content.summary.isNullOrEmpty())
|
||||
content.summary ?: ""
|
||||
else
|
||||
""
|
||||
} else if(content is JSWeb) {
|
||||
if(!content.url.isNullOrEmpty())
|
||||
"WEB:" + content.url
|
||||
else
|
||||
""
|
||||
} else "";
|
||||
|
||||
if (content.name.isNullOrEmpty()) {
|
||||
@@ -154,7 +166,14 @@ class PreviewPostView : LinearLayout {
|
||||
|
||||
if (content is IPlatformPost) {
|
||||
setImages(content.thumbnails.filterNotNull());
|
||||
} else {
|
||||
}
|
||||
else if(content is IPlatformArticle) {
|
||||
if(content.thumbnails != null)
|
||||
setImages(listOf(content.thumbnails!!));
|
||||
else
|
||||
setImages(null);
|
||||
}
|
||||
else {
|
||||
setImages(null);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -233,7 +233,7 @@ open class PreviewVideoView : LinearLayout {
|
||||
}
|
||||
else {
|
||||
currentVideo = null;
|
||||
_imageVideo.setImageResource(0);
|
||||
_imageVideo.setImageDrawable(null);
|
||||
_containerDuration.visibility = GONE;
|
||||
_containerLive.visibility = GONE;
|
||||
_timeBar?.visibility = GONE;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user