Compare commits

...

25 Commits

Author SHA1 Message Date
Kelvin K 414b6e24d2 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-06-10 12:57:28 +02:00
Kelvin K 9499afd815 Twitch refs 2024-06-10 12:57:17 +02:00
Koen e7aca5cd25 Merge branch 'ian-master-patch-14410' into 'master'
Update LICENSE.md

See merge request videostreaming/grayjay!20
2024-06-08 06:50:24 +00:00
Ian Mason 80a6a8ac9f Update LICENSE.md 2024-06-07 23:34:03 +00:00
Kelvin c3428a695f Merge branch 'channel-playlists-ui' into 'master'
add support for channel playlists on the channel page

See merge request videostreaming/grayjay!18
2024-06-07 15:20:20 +00:00
Kelvin 1a9665b5c6 Merge branch 'disable-spotify-download' into 'master'
disable download button for widevine sources

See merge request videostreaming/grayjay!19
2024-06-07 15:19:25 +00:00
Kai DeLorenzo ebb4693425 adjust tab order 2024-06-07 09:53:19 -05:00
Kelvin K 4f09f48ace Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-06-07 12:52:36 +02:00
Kelvin K a0d6ff912b App version info for plugins, trust all certs dev setting, latest refs 2024-06-07 12:52:25 +02:00
Koen a345da0feb Added spotify plugin. Fixed bilibili signing. Added bilibili and spotify link handling. 2024-06-07 09:32:16 +02:00
Kai DeLorenzo fc5a8d9531 disable download button for widevine sources 2024-06-06 17:33:35 -05:00
Koen 7353edb058 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-06-06 20:07:18 +02:00
Koen 2a7c0a5c79 Changed share intent. 2024-06-06 20:07:13 +02:00
Kai DeLorenzo 4cf3aabe89 removed additional hardcoding 2024-06-05 18:57:43 -05:00
Kai DeLorenzo ef284ba51d fixed tab changing when adding the playlist tab 2024-06-05 13:44:05 -05:00
Kai DeLorenzo 5edd389e84 removed hardcoding. fixed bugs. hide CHANNELS and SUPPORT for non polycentric linked channels 2024-06-04 20:22:42 -05:00
Koen 309332ac9c Update LICENSE 2024-06-03 16:30:27 +00:00
Koen 035d19f581 Update LICENSE 2024-06-03 16:30:05 +00:00
Kelvin 72bb43f934 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-05-31 21:59:17 +02:00
Kelvin 447ed6bf21 Live chat interval removel, non-self return http calls to prevent crash, minor doc fix, more logs 2024-05-31 21:59:07 +02:00
Koen db1bcfcc6b Allow user certificates to do full network request proxying. 2024-05-31 11:25:34 +02:00
Kai DeLorenzo 1ccae84933 add support for channel playlists on the channel page 2024-05-28 17:05:35 -05:00
Kelvin 152b9b23cd Intercept non-implemented getChannelPlaylists 2024-05-27 01:15:37 +02:00
Kelvin a3070d8d08 getChannelPlaylist support 2024-05-27 01:13:32 +02:00
Kelvin aceab7b476 Websocket fixes, onConcluded support 2024-05-21 22:31:04 +02:00
49 changed files with 1086 additions and 437 deletions
+6
View File
@@ -64,3 +64,9 @@
[submodule "app/src/stable/assets/sources/bilibili"]
path = app/src/stable/assets/sources/bilibili
url = ../plugins/bilibili.git
[submodule "app/src/stable/assets/sources/spotify"]
path = app/src/stable/assets/sources/spotify
url = ../plugins/spotify.git
[submodule "app/src/unstable/assets/sources/spotify"]
path = app/src/unstable/assets/sources/spotify
url = ../plugins/spotify.git
-32
View File
@@ -1,32 +0,0 @@
# FUTO TEMPORARY LICENSE
This license grants you the rights, and only the rights, set out below in respect of the source code provided. If you take advantage of these rights, you accept this license. If you do not accept the license, do not access the code.
Words used in the Terms of Service have the same meaning in this license. Where there is any inconsistency between this license and those Terms of Service, these terms prevail.
## Section 1: Definitions
- "code" means the source code made available from time, in our sole discretion, for access under this license. Reference to code in this license means the code and any part of it and any derivative of it.
- “compilation” means to compile the code from source code to machine code.
- "defect" means a defect, bug, backdoor, security issue or other deficiency in the code.
- “non-commercial distribution” means distribution of the code or any compilation of the code, or of any other application or program containing the code or any compilation of the code, where such distribution is not intended for or directed towards commercial advantage or monetary compensation.
- "review" means to access, analyse, test and otherwise review the code as a reference, for the sole purpose of analysing it for defects.
- "you" means the licensee of rights set out in this license.
## Section 2: Grant of Rights
1. Subject to the terms of this license, we grant you a non-transferable, non-exclusive, worldwide, royalty-free license to access and use the code solely for the purposes of review, compilation and non-commercial distribution.
2. You may provide the code to anyone else and publish excerpts of it for the purposes of review, compilation and non-commercial distribution, provided that when you do so you make any recipient of the code aware of the terms of this license, they must agree to be bound by the terms of this license and you must attribute the code to the provider.
3. Other than in respect of those parts of the code that were developed by other parties and as specified strictly in accordance with the open source and other licenses under which those parts of the code have been made available, as set out on our website or in those items of code, you are not entitled to use or do anything with the code for any commercial or other purpose, other than review, compilation and non-commercial distribution in accordance with the terms of this license.
4. Subject to the terms of this license, you must at all times comply with and shall be bound by our Terms of Use, Privacy and Data Policy.
## Section 3: Limitations
1. This license does not grant you any rights to use the provider's name, logo, or trademarks and you must not in any way indicate you are authorised to speak on behalf of the provider.
2. If you issue proceedings in any jurisdiction against the provider because you consider the provider has infringed copyright or any patent right in respect of the code (including any joinder or counterclaim), your license to the code is automatically terminated.
3. THE CODE IS MADE AVAILABLE "AS-IS" AND WITHOUT ANY EXPRESS OR IMPLIED GUARANTEES AS TO FITNESS, MERCHANTABILITY, NON-INFRINGEMENT OR OTHERWISE. IT IS NOT BEING PROVIDED IN TRADE BUT ON A VOLUNTARY BASIS ON OUR PART AND IS NOT MADE AVAILABLE FOR ANY USE OUTSIDE THE TERMS OF THIS LICENSE. ANYONE ACCESSING THE CODE MUST ENSURE THEY HAVE THE REQUISITE EXPERTISE TO SECURE THEIR OWN SYSTEM AND DEVICES AND TO ACCESS AND USE THE CODE IN ACCORDANCE WITH THE TERMS OF THIS LICENSE. YOU BEAR THE RISK OF ACCESSING AND USING THE CODE. IN PARTICULAR, THE PROVIDER BEARS NO LIABILITY FOR ANY INTERFERENCE WITH OR ADVERSE EFFECT ON YOUR SYSTEM OR DEVICES AS A RESULT OF YOUR ACCESSING AND USING THE CODE IN ACCORDANCE WITH THE TERMS OF THIS LICENSE OR OTHERWISE.
## Section 4: Termination, suspension and variation
1. We may suspend, terminate or vary the terms of this license and any access to the code at any time, without notice, for any reason or no reason, in respect of any licensee, group of licensees or all licensees including as may be applicable any sub-licensees.
## Section 5: General
1. This license and its interpretation and operation are governed solely by the local law. You agree to submit to the exclusive jurisdiction of the local arbitral tribunals as further described in our Terms of Service and you agree not to raise any jurisdictional issue if we need to enforce an arbitral award or judgment in our jurisdiction or another country.
2. Questions and comments regarding this license are welcomed and should be addressed at https://chat.futo.org/login/.
Last updated 7 June 2023.
+43
View File
@@ -0,0 +1,43 @@
# Grayjay Core License 1.0
## Acceptance
By using the software, you agree to all of the terms and conditions below.
## Copyright License
FUTO Holdings, Inc. (the “Licensor”) grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject to the limitations below.
## Limitations
You may use or modify the software only for non-commercial purposes such as personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, all without any anticipated commercial application.
You may distribute the software or provide it to others only if you do so free of charge for non-commercial purposes.
Notwithstanding the above, you may not remove or obscure any functionality in the software related to payment to the Licensor in any copy you distribute to others.
You may not alter, remove, or obscure any licensing, copyright, or other notices of the Licensor in the software. Any use of the Licensors trademarks is subject to applicable law.
## Patents
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
## Notices
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms. If you modify the software, you must include in any modified copies of the software a prominent notice stating that you have modified the software, such as but not limited to, a statement in a readme file or an in-application about section.
## Fair Use
You may have "fair use" rights for the software under the law. These terms do not limit them.
## No Other Rights
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the Licensor from granting licenses to anyone else. These terms do not imply any other licenses.
## Termination
If you use the software in violation of these terms, such use is not licensed, and your license will automatically terminate. If the licensor provides you with a notice of your violation, and you cease all violation of this license no later than 30 days after you receive that notice, your license will be reinstated retroactively. However, if you violate these terms after such reinstatement, any additional violation of these terms will cause your license to terminate automatically and permanently.
## No Liability
As far as the law allows, the software comes as is, without any warranty or condition, and the Licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.
## Definitions
- The “Licensor” is the entity offering these terms, FUTO Holdings, Inc.
- The “software” is the software the licensor makes available under these terms, including any portion of it.
- “You” refers to the individual or entity agreeing to these terms.
- “Your company” is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. Control means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
- “Your license” is the license granted to you for the software under these terms.
- “Use” means anything you do with the software requiring your license.
- “Trademark” means trademarks, service marks, and similar rights.
+1 -1
View File
@@ -127,7 +127,7 @@ declare class PlatformVideoDetails extends PlatformVideo {
}
declare interface PlatformPostDef extends PlatformContentDef {
thumbnails: string[],
thumbnails: Thumbnails[],
images: string[],
description: string
}
@@ -33,6 +33,7 @@ import com.futo.platformplayer.stores.FragmentedStorageFileJson
import com.futo.platformplayer.views.fields.ButtonField
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormField
import com.futo.platformplayer.views.fields.FormFieldWarning
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -493,6 +494,17 @@ class SettingsDev : FragmentedStorageFileJson() {
}
@FormField(R.string.networking, FieldForm.GROUP, -1, 18)
var networking = Networking();
@Serializable
class Networking {
@FormField(R.string.allow_all_certificates, FieldForm.TOGGLE, -1, 0)
@FormFieldWarning(R.string.allow_all_certificates_warning)
var allowAllCertificates: Boolean = false;
}
@Contextual
@Transient
@FormField(R.string.info, FieldForm.GROUP, -1, 19)
@@ -503,6 +515,8 @@ class SettingsDev : FragmentedStorageFileJson() {
var channelCacheStartupCount = StateCache.instance.channelCacheStartupCount;
}
//region BOILERPLATE
override fun encode(): String {
return Json.encodeToString(this);
@@ -6,6 +6,7 @@ import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.util.TypedValue
import android.view.View
@@ -19,7 +20,12 @@ import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.polycentric.core.*
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.SignedEvent
import com.futo.polycentric.core.StorageTypeCRDTItem
import com.futo.polycentric.core.StorageTypeCRDTSetItem
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.toBase64Url
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
import com.google.zxing.common.BitMatrix
@@ -64,11 +70,8 @@ class PolycentricBackupActivity : AppCompatActivity() {
}
_buttonShare.onClick.subscribe {
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain";
putExtra(Intent.EXTRA_TEXT, _exportBundle);
}
startActivity(Intent.createChooser(shareIntent, getString(R.string.share_text)));
val shareIntent = Intent(Intent.ACTION_VIEW, Uri.parse(_exportBundle))
startActivity(Intent.createChooser(shareIntent, "Share ID"));
};
_buttonCopy.onClick.subscribe {
@@ -1,5 +1,7 @@
package com.futo.platformplayer.api.http
import androidx.collection.arrayMapOf
import com.futo.platformplayer.SettingsDev
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.logging.Logger
@@ -13,6 +15,11 @@ import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
import kotlin.system.measureTimeMillis
open class ManagedHttpClient {
@@ -25,8 +32,29 @@ open class ManagedHttpClient {
var user_agent = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"
private val trustAllCerts = arrayOf<TrustManager>(
object: X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
override fun getAcceptedIssuers(): Array<X509Certificate> {
return arrayOf();
}
}
);
private fun trustAllCertificates(builder: OkHttpClient.Builder) {
val sslContext = SSLContext.getInstance("SSL");
sslContext.init(null, trustAllCerts, SecureRandom());
builder.sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager);
builder.hostnameVerifier { a, b ->
return@hostnameVerifier true;
}
Logger.w(TAG, "Creating INSECURE client (TrustAll)");
}
constructor(builder: OkHttpClient.Builder = OkHttpClient.Builder()) {
_builderTemplate = builder;
if(SettingsDev.instance.developerMode && SettingsDev.instance.networking.allowAllCertificates)
trustAllCertificates(builder);
client = builder.addNetworkInterceptor { chain ->
val request = beforeRequest(chain.request());
val response = afterRequest(chain.proceed(request));
@@ -10,6 +10,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.models.ImageVariable
@@ -60,6 +61,8 @@ class CachedPlatformClient : IPlatformClient {
filters: Map<String, List<String>>?
): IPager<IPlatformContent> = _client.getChannelContents(channelUrl);
override fun getChannelPlaylists(channelUrl: String): IPager<IPlatformPlaylist> = _client.getChannelPlaylists(channelUrl);
override fun getPeekChannelTypes(): List<String> = _client.getPeekChannelTypes();
override fun peekChannelContents(channelUrl: String, type: String?): List<IPlatformContent> = _client.peekChannelContents(channelUrl, type);
@@ -10,6 +10,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.models.ImageVariable
@@ -93,6 +94,11 @@ interface IPlatformClient {
*/
fun peekChannelContents(channelUrl: String, type: String? = null): List<IPlatformContent>
/**
* Gets all playlists of a channel
*/
fun getChannelPlaylists(channelUrl: String): IPager<IPlatformPlaylist>
/**
* Gets the channel url associated with a claimType
*/
@@ -18,7 +18,8 @@ data class PlatformClientCapabilities(
val hasGetLiveEvents: Boolean = false,
val hasGetLiveChatWindow: Boolean = false,
val hasGetContentChapters: Boolean = false,
val hasPeekChannelContents: Boolean = false
val hasPeekChannelContents: Boolean = false,
val hasGetChannelPlaylists: Boolean = false
) {
}
@@ -3,4 +3,5 @@ package com.futo.platformplayer.api.media.models.live
interface ILiveChatWindowDescriptor {
val url: String;
val removeElements: List<String>;
val removeElementsInterval: List<String>;
}
@@ -7,4 +7,5 @@ interface IPlaybackTracker {
fun onInit(seconds: Double);
fun onProgress(seconds: Double, isPlaying: Boolean);
fun onConcluded();
}
@@ -8,6 +8,8 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDeveloper
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.UUID
class DevJSClient : JSClient {
@@ -115,7 +117,7 @@ class DevJSClient : JSClient {
//Video
override fun isContentDetailsUrl(url: String): Boolean {
return StateDeveloper.instance.handleDevCall(devID, "isVideoDetailsUrl"){
return StateDeveloper.instance.handleDevCall(devID, "isVideoDetailsUrl(${Json.encodeToString(url)})"){
super.isContentDetailsUrl(url);
};
}
@@ -20,6 +20,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.platforms.js.internal.JSCallDocs
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs
@@ -39,6 +40,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSLiveChatWindowDes
import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaybackTracker
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistPager
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event1
@@ -231,7 +233,8 @@ open class JSClient : IPlatformClient {
hasGetLiveEvents = plugin.executeBoolean("!!source.getLiveEvents") ?: false,
hasGetLiveChatWindow = plugin.executeBoolean("!!source.getLiveChatWindow") ?: false,
hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false,
hasPeekChannelContents = plugin.executeBoolean("!!source.peekChannelContents") ?: false
hasPeekChannelContents = plugin.executeBoolean("!!source.peekChannelContents") ?: false,
hasGetChannelPlaylists = plugin.executeBoolean("!!source.getChannelPlaylists") ?: false
);
try {
@@ -402,6 +405,16 @@ open class JSClient : IPlatformClient {
plugin.executeTyped("source.getChannelContents(${Json.encodeToString(channelUrl)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})"));
}
@JSDocs(10, "source.getChannelPlaylists(url)", "Gets playlists of a channel")
@JSDocsParameter("channelUrl", "A channel url (this platform)")
override fun getChannelPlaylists(channelUrl: String): IPager<IPlatformPlaylist> = isBusyWith("getChannelPlaylists") {
ensureEnabled();
if(!capabilities.hasGetChannelPlaylists)
return@isBusyWith EmptyPager();
return@isBusyWith JSPlaylistPager(config, this,
plugin.executeTyped("source.getChannelPlaylists(${Json.encodeToString(channelUrl)})"));
}
@JSDocs(10, "source.getPeekChannelTypes()", "Gets types this plugin has for peek channel contents")
override fun getPeekChannelTypes(): List<String> {
if(!capabilities.hasPeekChannelContents)
@@ -423,6 +436,7 @@ open class JSClient : IPlatformClient {
return listOf();
}
}
@JSDocs(10, "source.peekChannelContents(url, type)", "Peek contents of a channel (reverse chronological order)")
@JSDocsParameter("channelUrl", "A channel url (this platform)")
@JSDocsParameter("type", "(optional) Type of contents to get from channel")
@@ -7,6 +7,7 @@ import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
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.getOrDefaultList
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullable
@@ -37,7 +38,7 @@ class JSChannel : IPlatformChannel {
description = _channel.getOrThrowNullable(config, "description", contextName);
url = _channel.getOrThrow(config, "url", contextName);
urlAlternatives = _channel.getOrDefaultList(config, "urlAlternatives", contextName, listOf()) ?: listOf();
links = HashMap();
links = HashMap(_channel.getOrDefault<Map<String, String>>(config, "links", contextName, mapOf()) ?: mapOf());
}
override fun getContents(client: IPlatformClient): IPager<IPlatformContent> {
@@ -14,12 +14,13 @@ import java.time.ZoneOffset
class JSLiveChatWindowDescriptor: ILiveChatWindowDescriptor {
override val url: String;
override val removeElements: List<String>;
override val removeElementsInterval: List<String>;
constructor(config: SourcePluginConfig, obj: V8ValueObject) {
val contextName = "LiveChatWindowDescriptor";
url = obj.getOrThrow(config, "url", contextName);
removeElements = obj.getOrDefault(config, "removeElements", contextName, listOf()) ?: listOf();
removeElementsInterval = obj.getOrDefault(config, "removeElementsInterval", contextName, listOf()) ?: listOf();
}
}
@@ -17,6 +17,8 @@ class JSPlaybackTracker: IPlaybackTracker {
private var _lastRequest: Long = Long.MIN_VALUE;
private val _hasOnConcluded: Boolean;
override var nextRequest: Int = 1000
private set;
@@ -26,6 +28,7 @@ class JSPlaybackTracker: IPlaybackTracker {
throw ScriptImplementationException(config, "Missing onProgress on PlaybackTracker");
if(!obj.has("nextRequest"))
throw ScriptImplementationException(config, "Missing nextRequest on PlaybackTracker");
_hasOnConcluded = obj.has("onConcluded");
this._config = config;
this._obj = obj;
@@ -59,6 +62,16 @@ class JSPlaybackTracker: IPlaybackTracker {
}
}
}
override fun onConcluded() {
warnIfMainThread("JSPlaybackTracker.onConcluded");
if(_hasOnConcluded) {
synchronized(_obj) {
Logger.i("JSPlaybackTracker", "onConcluded");
_obj.invokeVoid("onConcluded", -1);
}
}
}
override fun shouldUpdate(): Boolean = (_lastRequest < 0 || (System.currentTimeMillis() - _lastRequest) > nextRequest);
}
@@ -1,6 +1,8 @@
package com.futo.platformplayer.engine.packages
import com.caoccao.javet.annotations.V8Function
import com.caoccao.javet.annotations.V8Property
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.UIDialogs
@@ -35,6 +37,19 @@ class PackageBridge : V8Package {
_clientAuth = plugin.httpClientAuth;
}
@V8Property
fun buildVersion(): Int {
//If debug build, assume max version
if(BuildConfig.VERSION_CODE == 1)
return Int.MAX_VALUE;
return BuildConfig.VERSION_CODE;
}
@V8Property
fun buildFlavor(): String {
return BuildConfig.FLAVOR;
}
@V8Function
fun toast(str: String) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
@@ -191,6 +191,8 @@ class PackageHttp: V8Package {
@Transient
private val _client: ManagedHttpClient;
val parentConfig: IV8PluginConfig get() = _package._config;
@Transient
private val _defaultHeaders = mutableMapOf<String, String>();
@Transient
@@ -209,28 +211,24 @@ class PackageHttp: V8Package {
}
@V8Function
fun setDefaultHeaders(defaultHeaders: Map<String, String>): PackageHttpClient {
fun setDefaultHeaders(defaultHeaders: Map<String, String>) {
for(pair in defaultHeaders)
_defaultHeaders[pair.key] = pair.value;
return this;
}
@V8Function
fun setDoApplyCookies(apply: Boolean): PackageHttpClient {
fun setDoApplyCookies(apply: Boolean) {
if(_client is JSHttpClient)
_client.doApplyCookies = apply;
return this;
}
@V8Function
fun setDoUpdateCookies(update: Boolean): PackageHttpClient {
fun setDoUpdateCookies(update: Boolean) {
if(_client is JSHttpClient)
_client.doUpdateCookies = update;
return this;
}
@V8Function
fun setDoAllowNewCookies(allow: Boolean): PackageHttpClient {
fun setDoAllowNewCookies(allow: Boolean) {
if(_client is JSHttpClient)
_client.doAllowNewCookies = allow;
return this;
}
@V8Function
@@ -431,7 +429,7 @@ class PackageHttp: V8Package {
val hasClosed = socketObj.has("closed");
val hasFailure = socketObj.has("failure");
//socketObj.setWeak(); //We have to manage this lifecycle
socketObj.setWeak(); //We have to manage this lifecycle
_listeners = socketObj;
_socket = _packageClient.logExceptions {
@@ -440,8 +438,14 @@ class PackageHttp: V8Package {
override fun open() {
Logger.i(TAG, "Websocket opened: " + _url);
_isOpen = true;
if(hasOpen)
_listeners?.invokeVoid("open", arrayOf<Any>());
if(hasOpen) {
try {
_listeners?.invokeVoid("open", arrayOf<Any>());
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] open failed: " + ex.message, ex);
}
}
}
override fun message(msg: String) {
if(hasMessage) {
@@ -453,18 +457,37 @@ class PackageHttp: V8Package {
}
override fun closing(code: Int, reason: String) {
if(hasClosing)
_listeners?.invokeVoid("closing", code, reason);
{
try {
_listeners?.invokeVoid("closing", code, reason);
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closing failed: " + ex.message, ex);
}
}
}
override fun closed(code: Int, reason: String) {
_isOpen = false;
if(hasClosed)
_listeners?.invokeVoid("closed", code, reason);
if(hasClosed) {
try {
_listeners?.invokeVoid("closed", code, reason);
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
}
}
}
override fun failure(exception: Throwable) {
_isOpen = false;
Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception);
if(hasFailure)
_listeners?.invokeVoid("failure", exception.message);
if(hasFailure) {
try {
_listeners?.invokeVoid("failure", exception.message);
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
}
}
}
});
};
@@ -474,6 +497,16 @@ class PackageHttp: V8Package {
fun send(msg: String) {
_socket?.send(msg);
}
@V8Function
fun close() {
_socket?.close(1000, "");
}
@V8Function
fun close(code: Int?, reason: String?) {
_socket?.close(code ?: 1000, reason ?: "");
_listeners?.close()
}
}
data class RequestDescriptor(
@@ -114,7 +114,7 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment {
}
fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) {
override fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) {
_lastPolycentricProfile = polycentricProfile;
if (polycentricProfile == null) {
@@ -309,7 +309,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
_adapterResults?.setLoading(loading);
}
fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) {
override fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) {
val p = _lastPolycentricProfile;
if (p != null && polycentricProfile != null && p.system == polycentricProfile.system) {
Logger.i(TAG, "setPolycentricProfile skipped because previous was same");
@@ -124,7 +124,7 @@ class ChannelListFragment : Fragment, IChannelTabFragment {
}
}
fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) {
override fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) {
_taskLoadChannel.cancel();
_lastPolycentricProfile = polycentricProfile;
@@ -46,7 +46,7 @@ class ChannelMonetizationFragment : Fragment, IChannelTabFragment {
_lastChannel = channel;
}
fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) {
override fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) {
_lastPolycentricProfile = polycentricProfile
if (polycentricProfile != null) {
_supportView?.setPolycentricProfile(polycentricProfile)
@@ -0,0 +1,297 @@
package com.futo.platformplayer.fragment.channel.tab
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.platforms.js.models.JSPager
import com.futo.platformplayer.api.media.structures.IAsyncPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.IRefreshPager
import com.futo.platformplayer.api.media.structures.IReplacerPager
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.engine.exceptions.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment {
private var _recyclerResults: RecyclerView? = null
private var _llmPlaylist: LinearLayoutManager? = null
private var _loading = false
private var _pagerParent: IPager<IPlatformPlaylist>? = null
private var _pager: IPager<IPlatformPlaylist>? = null
private var _channel: IPlatformChannel? = null
private var _results: ArrayList<IPlatformContent> = arrayListOf()
private var _adapterResults: InsertedViewAdapterWithLoader<ContentPreviewViewHolder>? = null
val onContentClicked = Event2<IPlatformContent, Long>()
val onContentUrlClicked = Event2<String, ContentType>()
val onUrlClicked = Event1<String>()
val onChannelClicked = Event1<PlatformAuthorLink>()
val onAddToClicked = Event1<IPlatformContent>()
val onAddToQueueClicked = Event1<IPlatformContent>()
val onAddToWatchLaterClicked = Event1<IPlatformContent>()
val onLongPress = Event1<IPlatformContent>()
private fun getPlaylistPager(channel: IPlatformChannel): IPager<IPlatformPlaylist> {
Logger.i(TAG, "getPlaylistPager")
return StatePlatform.instance.getChannelPlaylists(channel.url)
}
private val _taskLoadPlaylists =
TaskHandler<IPlatformChannel, IPager<IPlatformPlaylist>>({ lifecycleScope }, {
val livePager = getPlaylistPager(it)
return@TaskHandler livePager
}).success { livePager ->
setLoading(false)
setPager(livePager)
}.exception<ScriptCaptchaRequiredException> { }.exception<Throwable> {
Logger.w(TAG, "Failed to load initial playlists.", it)
UIDialogs.showGeneralRetryErrorDialog(requireContext(),
it.message ?: "",
it,
{ loadNextPage() })
}
private var _nextPageHandler: TaskHandler<IPager<IPlatformPlaylist>, List<IPlatformPlaylist>> =
TaskHandler<IPager<IPlatformPlaylist>, List<IPlatformPlaylist>>({ lifecycleScope }, {
if (it is IAsyncPager<*>) it.nextPageAsync()
else it.nextPage()
processPagerExceptions(it)
return@TaskHandler it.getResults()
}).success {
setLoading(false)
val posBefore = _results.size
_results.addAll(it)
_adapterResults?.let { adapterResult ->
adapterResult.notifyItemRangeInserted(
adapterResult.childToParentPosition(
posBefore
), it.size
)
}
}.exception<Throwable> {
Logger.w(TAG, "Failed to load next page.", it)
UIDialogs.showGeneralRetryErrorDialog(requireContext(),
it.message ?: "",
it,
{ loadNextPage() })
}
private val _scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val recyclerResults = _recyclerResults ?: return
val llmPlaylist = _llmPlaylist ?: return
val visibleItemCount = recyclerResults.childCount
val firstVisibleItem = llmPlaylist.findFirstVisibleItemPosition()
val visibleThreshold = 15
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= _results.size) {
loadNextPage()
}
}
}
override fun setChannel(channel: IPlatformChannel) {
val c = _channel
if (c != null && c.url == channel.url) {
Logger.i(TAG, "setChannel skipped because previous was same")
return
}
Logger.i(TAG, "setChannel setChannel=${channel}")
_taskLoadPlaylists.cancel()
_channel = channel
_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)
_recyclerResults = view.findViewById(R.id.recycler_videos)
_adapterResults = PreviewContentListAdapter(
view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar
).apply {
this.onContentUrlClicked.subscribe(this@ChannelPlaylistsFragment.onContentUrlClicked::emit)
this.onUrlClicked.subscribe(this@ChannelPlaylistsFragment.onUrlClicked::emit)
this.onContentClicked.subscribe(this@ChannelPlaylistsFragment.onContentClicked::emit)
this.onChannelClicked.subscribe(this@ChannelPlaylistsFragment.onChannelClicked::emit)
this.onAddToClicked.subscribe(this@ChannelPlaylistsFragment.onAddToClicked::emit)
this.onAddToQueueClicked.subscribe(this@ChannelPlaylistsFragment.onAddToQueueClicked::emit)
this.onAddToWatchLaterClicked.subscribe(this@ChannelPlaylistsFragment.onAddToWatchLaterClicked::emit)
this.onLongPress.subscribe(this@ChannelPlaylistsFragment.onLongPress::emit)
}
_llmPlaylist = LinearLayoutManager(view.context)
_recyclerResults?.adapter = _adapterResults
_recyclerResults?.layoutManager = _llmPlaylist
_recyclerResults?.addOnScrollListener(_scrollListener)
return view
}
override fun onDestroyView() {
super.onDestroyView()
_recyclerResults?.removeOnScrollListener(_scrollListener)
_recyclerResults = null
_pager = null
_taskLoadPlaylists.cancel()
_nextPageHandler.cancel()
}
private fun setPager(
pager: IPager<IPlatformPlaylist>
) {
if (_pagerParent != null && _pagerParent is IRefreshPager<*>) {
(_pagerParent as IRefreshPager<*>).onPagerError.remove(this)
(_pagerParent as IRefreshPager<*>).onPagerChanged.remove(this)
_pagerParent = null
}
if (_pager is IReplacerPager<*>) (_pager as IReplacerPager<*>).onReplaced.remove(this)
val pagerToSet: IPager<IPlatformPlaylist>?
if (pager is IRefreshPager<*>) {
_pagerParent = pager
pagerToSet = pager.getCurrentPager() as IPager<IPlatformPlaylist>
pager.onPagerChanged.subscribe(this) {
lifecycleScope.launch(Dispatchers.Main) {
try {
loadPagerInternal(it as IPager<IPlatformPlaylist>)
} catch (e: Throwable) {
Logger.e(TAG, "loadPagerInternal failed.", e)
}
}
}
pager.onPagerError.subscribe(this) {
Logger.e(TAG, "Search pager failed: ${it.message}", it)
if (it is PluginException) UIDialogs.toast("Plugin [${it.config.name}] failed due to:\n${it.message}")
else UIDialogs.toast("Plugin failed due to:\n${it.message}")
}
} else pagerToSet = pager
loadPagerInternal(pagerToSet)
}
private fun loadPagerInternal(
pager: IPager<IPlatformPlaylist>
) {
if (_pager is IReplacerPager<*>) (_pager as IReplacerPager<*>).onReplaced.remove(this)
if (pager is IReplacerPager<*>) {
pager.onReplaced.subscribe(this) { oldItem, newItem ->
if (_pager != pager) return@subscribe
lifecycleScope.launch(Dispatchers.Main) {
val toReplaceIndex = _results.indexOfFirst { it == oldItem }
if (toReplaceIndex >= 0) {
_results[toReplaceIndex] = newItem as IPlatformPlaylist
_adapterResults?.let {
it.notifyItemChanged(it.childToParentPosition(toReplaceIndex))
}
}
}
}
}
_pager = pager
processPagerExceptions(pager)
_results.clear()
val toAdd = pager.getResults()
_results.addAll(toAdd)
_adapterResults?.notifyDataSetChanged()
_recyclerResults?.scrollToPosition(0)
}
private fun loadInitial() {
val channel: IPlatformChannel = _channel ?: return
setLoading(true)
_taskLoadPlaylists.run(channel)
}
private fun loadNextPage() {
val pager: IPager<IPlatformPlaylist> = _pager ?: return
if (_pager?.hasMorePages() == true) {
setLoading(true)
_nextPageHandler.run(pager)
}
}
private fun setLoading(loading: Boolean) {
_loading = loading
_adapterResults?.setLoading(loading)
}
private fun processPagerExceptions(pager: IPager<*>) {
if (pager is MultiPager<*> && pager.allowFailure) {
val ex = pager.getResultExceptions()
for (kv in ex) {
val jsPager: JSPager<*>? = when (kv.key) {
is MultiPager<*> -> (kv.key as MultiPager<*>).findPager { it is JSPager<*> } as JSPager<*>?
is JSPager<*> -> kv.key as JSPager<*>
else -> null
}
context?.let {
lifecycleScope.launch(Dispatchers.Main) {
try {
val channel =
if (kv.value is ChannelException) (kv.value as ChannelException).channelNameOrUrl else null
if (jsPager != null) UIDialogs.toast(
it,
"Plugin ${jsPager.getPluginConfig().name} failed:\n" + (if (!channel.isNullOrEmpty()) "(${channel}) " else "") + "${kv.value.message}",
false
)
else UIDialogs.toast(it, kv.value.message ?: "", false)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to show toast.", e)
}
}
}
}
}
}
companion object {
const val TAG = "PlaylistsFragment"
fun newInstance() = ChannelPlaylistsFragment().apply { }
}
}
@@ -1,7 +1,11 @@
package com.futo.platformplayer.fragment.channel.tab
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
interface IChannelTabFragment {
fun setChannel(channel: IPlatformChannel);
}
fun setChannel(channel: IPlatformChannel)
fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) {
}
}
@@ -15,8 +15,9 @@ import androidx.appcompat.widget.AppCompatImageView
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.widget.ViewPager2
import com.bumptech.glide.Glide
import com.futo.platformplayer.*
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
@@ -27,26 +28,32 @@ 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.assume
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.fragment.channel.tab.ChannelAboutFragment
import com.futo.platformplayer.fragment.channel.tab.ChannelContentsFragment
import com.futo.platformplayer.fragment.channel.tab.ChannelListFragment
import com.futo.platformplayer.fragment.channel.tab.ChannelMonetizationFragment
import com.futo.platformplayer.dp
import com.futo.platformplayer.fragment.channel.tab.IChannelTabFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.selectHighestResolutionImage
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.adapters.ChannelTab
import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.polycentric.core.*
import com.futo.polycentric.core.OwnedClaim
import com.futo.polycentric.core.PublicKey
import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.Dispatchers
@@ -55,459 +62,530 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
@Serializable
data class PolycentricProfile(val system: PublicKey, val systemState: SystemState, val ownedClaims: List<OwnedClaim>);
data class PolycentricProfile(
val system: PublicKey, val systemState: SystemState, val ownedClaims: List<OwnedClaim>
)
class ChannelFragment : MainFragment() {
override val isMainView : Boolean = true;
override val hasBottomBar: Boolean = true;
private var _view: ChannelView? = null;
override val isMainView: Boolean = true
override val hasBottomBar: Boolean = true
private var _view: ChannelView? = null
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
_view?.onShown(parameter, isBack);
super.onShownWithView(parameter, isBack)
_view?.onShown(parameter, isBack)
}
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = ChannelView(this, inflater);
_view = view;
return view;
override fun onCreateMainView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
val view = ChannelView(this, inflater)
_view = view
return view
}
override fun onBackPressed(): Boolean {
return _view?.onBackPressed() ?: false;
return _view?.onBackPressed() ?: false
}
override fun onDestroyMainView() {
super.onDestroyMainView();
super.onDestroyMainView()
_view?.cleanup();
_view = null;
_view?.cleanup()
_view = null
}
fun selectTab(selectedTabIndex: Int) {
_view?.selectTab(selectedTabIndex);
fun selectTab(tab: ChannelTab) {
_view?.selectTab(tab)
}
@SuppressLint("ViewConstructor")
class ChannelView : LinearLayout {
private val _fragment: ChannelFragment;
class ChannelView
(fragment: ChannelFragment, inflater: LayoutInflater) : LinearLayout(inflater.context) {
private val _fragment: ChannelFragment = fragment
private var _textChannel: TextView;
private var _textChannelSub: TextView;
private var _creatorThumbnail: CreatorThumbnail;
private var _imageBanner: AppCompatImageView;
private var _textChannel: TextView
private var _textChannelSub: TextView
private var _creatorThumbnail: CreatorThumbnail
private var _imageBanner: AppCompatImageView
private var _tabs: TabLayout;
private var _viewPager: ViewPager2;
private var _tabLayoutMediator: TabLayoutMediator;
private var _buttonSubscribe: SubscribeButton;
private var _buttonSubscriptionSettings: ImageButton;
private var _tabs: TabLayout
private var _viewPager: ViewPager2
private var _overlayContainer: FrameLayout;
private var _overlay_loading: LinearLayout;
private var _overlay_loading_spinner: ImageView;
// private var _adapter: ChannelViewPagerAdapter;
private var _tabLayoutMediator: TabLayoutMediator
private var _buttonSubscribe: SubscribeButton
private var _buttonSubscriptionSettings: ImageButton
private var _slideUpOverlay: SlideUpMenuOverlay? = null;
private var _overlayContainer: FrameLayout
private var _overlayLoading: LinearLayout
private var _overlayLoadingSpinner: ImageView
private var _isLoading: Boolean = false;
private var _selectedTabIndex: Int = -1;
private var _slideUpOverlay: SlideUpMenuOverlay? = null
private var _isLoading: Boolean = false
private var _selectedTabIndex: Int = -1
var channel: IPlatformChannel? = null
private set;
private var _url: String? = null;
private set
private var _url: String? = null
private val _onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
super.onPageScrolled(position, positionOffset, positionOffsetPixels);
//recalculate(position, positionOffset);
}
}
private val _onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {}
private val _taskLoadPolycentricProfile: TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>;
private val _taskGetChannel: TaskHandler<String, IPlatformChannel>;
private val _taskLoadPolycentricProfile: TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>
private val _taskGetChannel: TaskHandler<String, IPlatformChannel>
constructor(fragment: ChannelFragment, inflater: LayoutInflater) : super(inflater.context) {
_fragment = fragment;
inflater.inflate(R.layout.fragment_channel, this);
_taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>({fragment.lifecycleScope}, { id ->
return@TaskHandler PolycentricCache.instance.getProfileAsync(id);
})
.success { it -> setPolycentricProfile(it, animate = true) }
.exception<Throwable> {
Logger.w(TAG, "Failed to load polycentric profile.", it);
};
_taskGetChannel = TaskHandler<String, IPlatformChannel>({fragment.lifecycleScope}, { url -> StatePlatform.instance.getChannelLive(url) })
.success { showChannel(it); }
init {
inflater.inflate(R.layout.fragment_channel, this)
_taskLoadPolycentricProfile =
TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>({ fragment.lifecycleScope },
{ id ->
return@TaskHandler PolycentricCache.instance.getProfileAsync(id)
}).success { setPolycentricProfile(it, animate = true) }.exception<Throwable> {
Logger.w(TAG, "Failed to load polycentric profile.", it)
}
_taskGetChannel = TaskHandler<String, IPlatformChannel>({ fragment.lifecycleScope },
{ url -> StatePlatform.instance.getChannelLive(url) }).success { showChannel(it); }
.exception<NoPlatformClientException> {
UIDialogs.showDialog(context,
UIDialogs.showDialog(
context,
R.drawable.ic_sources,
context.getString(R.string.no_source_enabled_to_support_this_channel) + "\n(${_url})", null, null,
context.getString(R.string.no_source_enabled_to_support_this_channel) + "\n(${_url})",
null,
null,
0,
UIDialogs.Action("Back", {
fragment.close(true);
fragment.close(true)
}, UIDialogs.ActionStyle.PRIMARY)
);
)
}.exception<Throwable> {
Logger.e(TAG, "Failed to load channel.", it)
UIDialogs.showGeneralRetryErrorDialog(
context, it.message ?: "", it, { loadChannel() }, null, fragment
)
}
.exception<Throwable> {
Logger.e(TAG, "Failed to load channel.", it);
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadChannel() }, null, fragment);
}
val tabs: TabLayout = findViewById(R.id.tabs);
val viewPager: ViewPager2 = findViewById(R.id.view_pager);
_textChannel = findViewById(R.id.text_channel_name);
_textChannelSub = findViewById(R.id.text_metadata);
_creatorThumbnail = findViewById(R.id.creator_thumbnail);
_imageBanner = findViewById(R.id.image_channel_banner);
_buttonSubscribe = findViewById(R.id.button_subscribe);
_buttonSubscriptionSettings = findViewById(R.id.button_sub_settings);
_overlay_loading = findViewById(R.id.channel_loading_overlay);
_overlay_loading_spinner = findViewById(R.id.channel_loader);
_overlayContainer = findViewById(R.id.overlay_container);
val tabs: TabLayout = findViewById(R.id.tabs)
val viewPager: ViewPager2 = findViewById(R.id.view_pager)
_textChannel = findViewById(R.id.text_channel_name)
_textChannelSub = findViewById(R.id.text_metadata)
_creatorThumbnail = findViewById(R.id.creator_thumbnail)
_imageBanner = findViewById(R.id.image_channel_banner)
_buttonSubscribe = findViewById(R.id.button_subscribe)
_buttonSubscriptionSettings = findViewById(R.id.button_sub_settings)
_overlayLoading = findViewById(R.id.channel_loading_overlay)
_overlayLoadingSpinner = findViewById(R.id.channel_loader)
_overlayContainer = findViewById(R.id.overlay_container)
_buttonSubscribe.onSubscribed.subscribe {
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer)
_buttonSubscriptionSettings.visibility =
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
}
_buttonSubscribe.onUnSubscribed.subscribe {
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
_buttonSubscriptionSettings.visibility =
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
}
_buttonSubscriptionSettings.setOnClickListener {
val url = channel?.url ?: _url ?: return@setOnClickListener;
val sub = StateSubscriptions.instance.getSubscription(url) ?: return@setOnClickListener;
UISlideOverlays.showSubscriptionOptionsOverlay(sub, _overlayContainer);
};
val url = channel?.url ?: _url ?: return@setOnClickListener
val sub =
StateSubscriptions.instance.getSubscription(url) ?: return@setOnClickListener
UISlideOverlays.showSubscriptionOptionsOverlay(sub, _overlayContainer)
}
//TODO: Determine if this is really the only solution (isSaveEnabled=false)
viewPager.isSaveEnabled = false;
viewPager.registerOnPageChangeCallback(_onPageChangeCallback);
val adapter = ChannelViewPagerAdapter(fragment.childFragmentManager, fragment.lifecycle);
viewPager.isSaveEnabled = false
viewPager.registerOnPageChangeCallback(_onPageChangeCallback)
val adapter = ChannelViewPagerAdapter(fragment.childFragmentManager, fragment.lifecycle)
adapter.onChannelClicked.subscribe { c -> fragment.navigate<ChannelFragment>(c) }
adapter.onContentClicked.subscribe { v, _ ->
if(v is IPlatformVideo) {
StatePlayer.instance.clearQueue();
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail();
} else if (v is IPlatformPlaylist) {
fragment.navigate<PlaylistFragment>(v);
} else if (v is IPlatformPost) {
fragment.navigate<PostDetailFragment>(v);
when (v) {
is IPlatformVideo -> {
StatePlayer.instance.clearQueue()
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail()
}
is IPlatformPlaylist -> {
fragment.navigate<PlaylistFragment>(v)
}
is IPlatformPost -> {
fragment.navigate<PostDetailFragment>(v)
}
}
}
adapter.onAddToClicked.subscribe {content ->
adapter.onAddToClicked.subscribe { content ->
_overlayContainer.let {
if(content is IPlatformVideo)
_slideUpOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it);
if (content is IPlatformVideo) _slideUpOverlay =
UISlideOverlays.showVideoOptionsOverlay(content, it)
}
}
adapter.onAddToQueueClicked.subscribe { content ->
if(content is IPlatformVideo) {
StatePlayer.instance.addToQueue(content);
if (content is IPlatformVideo) {
StatePlayer.instance.addToQueue(content)
}
}
adapter.onAddToWatchLaterClicked.subscribe { content ->
if(content is IPlatformVideo) {
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content));
UIDialogs.toast("Added to watch later\n[${content.name}]");
if (content is IPlatformVideo) {
StatePlaylists.instance.addToWatchLater(
SerializedPlatformVideo.fromVideo(
content
)
)
UIDialogs.toast("Added to watch later\n[${content.name}]")
}
}
adapter.onUrlClicked.subscribe { url ->
fragment.navigate<BrowserFragment>(url);
fragment.navigate<BrowserFragment>(url)
}
adapter.onContentUrlClicked.subscribe { url, contentType ->
when(contentType) {
when (contentType) {
ContentType.MEDIA -> {
StatePlayer.instance.clearQueue();
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail();
};
ContentType.URL -> fragment.navigate<BrowserFragment>(url);
else -> {};
StatePlayer.instance.clearQueue()
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
}
ContentType.URL -> fragment.navigate<BrowserFragment>(url)
else -> {}
}
}
adapter.onLongPress.subscribe { content ->
_overlayContainer.let {
if(content is IPlatformVideo)
_slideUpOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it);
if (content is IPlatformVideo) _slideUpOverlay =
UISlideOverlays.showVideoOptionsOverlay(content, it)
}
}
viewPager.adapter = adapter;
val tabLayoutMediator = TabLayoutMediator(tabs, viewPager) { tab, position ->
tab.text = when (position) {
0 -> "VIDEOS"
1 -> "CHANNELS"
//2 -> "STORE"
2 -> "SUPPORT"
3 -> "ABOUT"
else -> "Unknown $position"
};
};
tabLayoutMediator.attach();
_tabLayoutMediator = tabLayoutMediator;
_tabs = tabs;
_viewPager = viewPager;
viewPager.adapter = adapter
val tabLayoutMediator = TabLayoutMediator(
tabs, viewPager, (viewPager.adapter as ChannelViewPagerAdapter)::getTabNames
)
tabLayoutMediator.attach()
_tabLayoutMediator = tabLayoutMediator
_tabs = tabs
_viewPager = viewPager
if (_selectedTabIndex != -1) {
selectTab(_selectedTabIndex);
selectTab(_selectedTabIndex)
}
setLoading(true)
}
setLoading(true);
fun selectTab(tab: ChannelTab) {
(_viewPager.adapter as ChannelViewPagerAdapter).getTabPosition(tab)
}
fun cleanup() {
_taskLoadPolycentricProfile.cancel();
_taskGetChannel.cancel();
_tabLayoutMediator.detach();
_viewPager.unregisterOnPageChangeCallback(_onPageChangeCallback);
hideSlideUpOverlay();
(_overlay_loading_spinner.drawable as Animatable?)?.stop();
_taskLoadPolycentricProfile.cancel()
_taskGetChannel.cancel()
_tabLayoutMediator.detach()
_viewPager.unregisterOnPageChangeCallback(_onPageChangeCallback)
hideSlideUpOverlay()
(_overlayLoadingSpinner.drawable as Animatable?)?.stop()
}
fun onShown(parameter: Any?, isBack: Boolean) {
hideSlideUpOverlay();
_taskLoadPolycentricProfile.cancel();
_selectedTabIndex = -1;
hideSlideUpOverlay()
_taskLoadPolycentricProfile.cancel()
_selectedTabIndex = -1
if (!isBack || _url == null) {
_imageBanner.setImageDrawable(null);
_imageBanner.setImageDrawable(null)
if (parameter is String) {
_buttonSubscribe.setSubscribeChannel(parameter);
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
setPolycentricProfileOr(parameter) {
_textChannel.text = "";
_textChannelSub.text = "";
_creatorThumbnail.setThumbnail(null, true);
Glide.with(_imageBanner)
.clear(_imageBanner);
};
when (parameter) {
is String -> {
_buttonSubscribe.setSubscribeChannel(parameter)
_buttonSubscriptionSettings.visibility =
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
setPolycentricProfileOr(parameter) {
_textChannel.text = ""
_textChannelSub.text = ""
_creatorThumbnail.setThumbnail(null, true)
Glide.with(_imageBanner).clear(_imageBanner)
}
_url = parameter;
loadChannel();
} else if (parameter is SerializedChannel) {
showChannel(parameter);
_url = parameter.url;
loadChannel();
} else if (parameter is IPlatformChannel)
showChannel(parameter);
else if (parameter is PlatformAuthorLink) {
setPolycentricProfileOr(parameter.url) {
_textChannel.text = parameter.name;
_textChannelSub.text = "";
_creatorThumbnail.setThumbnail(parameter.thumbnail, true);
Glide.with(_imageBanner)
.clear(_imageBanner);
_url = parameter
loadChannel()
}
loadPolycentricProfile(parameter.id, parameter.url)
};
is SerializedChannel -> {
showChannel(parameter)
_url = parameter.url
loadChannel()
}
_url = parameter.url;
loadChannel();
} else if (parameter is Subscription) {
setPolycentricProfileOr(parameter.channel.url) {
_textChannel.text = parameter.channel.name;
_textChannelSub.text = "";
_creatorThumbnail.setThumbnail(parameter.channel.thumbnail, true);
Glide.with(_imageBanner)
.clear(_imageBanner);
is IPlatformChannel -> showChannel(parameter)
is PlatformAuthorLink -> {
setPolycentricProfileOr(parameter.url) {
_textChannel.text = parameter.name
_textChannelSub.text = ""
_creatorThumbnail.setThumbnail(parameter.thumbnail, true)
Glide.with(_imageBanner).clear(_imageBanner)
loadPolycentricProfile(parameter.channel.id, parameter.channel.url)
};
loadPolycentricProfile(parameter.id, parameter.url)
}
_url = parameter.channel.url;
loadChannel();
_url = parameter.url
loadChannel()
}
is Subscription -> {
setPolycentricProfileOr(parameter.channel.url) {
_textChannel.text = parameter.channel.name
_textChannelSub.text = ""
_creatorThumbnail.setThumbnail(parameter.channel.thumbnail, true)
Glide.with(_imageBanner).clear(_imageBanner)
loadPolycentricProfile(parameter.channel.id, parameter.channel.url)
}
_url = parameter.channel.url
loadChannel()
}
}
} else {
loadChannel();
loadChannel()
}
}
fun selectTab(selectedTabIndex: Int) {
_selectedTabIndex = selectedTabIndex;
_tabs.selectTab(_tabs.getTabAt(selectedTabIndex));
private fun selectTab(selectedTabIndex: Int) {
_selectedTabIndex = selectedTabIndex
_tabs.selectTab(_tabs.getTabAt(selectedTabIndex))
}
private fun loadPolycentricProfile(id: PlatformID, url: String) {
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(url, true);
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(url, true)
if (cachedPolycentricProfile != null) {
setPolycentricProfile(cachedPolycentricProfile, animate = true)
if (cachedPolycentricProfile.expired) {
_taskLoadPolycentricProfile.run(id);
_taskLoadPolycentricProfile.run(id)
}
} else {
_taskLoadPolycentricProfile.run(id);
_taskLoadPolycentricProfile.run(id)
}
}
private fun setLoading(isLoading: Boolean) {
if (_isLoading == isLoading) {
return;
return
}
_isLoading = isLoading;
if(isLoading){
_overlay_loading.visibility = View.VISIBLE;
(_overlay_loading_spinner.drawable as Animatable?)?.start();
}
else {
(_overlay_loading_spinner.drawable as Animatable?)?.stop();
_overlay_loading.visibility = View.GONE;
_isLoading = isLoading
if (isLoading) {
_overlayLoading.visibility = View.VISIBLE
(_overlayLoadingSpinner.drawable as Animatable?)?.start()
} else {
(_overlayLoadingSpinner.drawable as Animatable?)?.stop()
_overlayLoading.visibility = View.GONE
}
}
fun onBackPressed(): Boolean {
if (_slideUpOverlay != null) {
hideSlideUpOverlay();
return true;
hideSlideUpOverlay()
return true
}
return false;
return false
}
private fun hideSlideUpOverlay() {
_slideUpOverlay?.hide(false);
_slideUpOverlay = null;
_slideUpOverlay?.hide(false)
_slideUpOverlay = null
}
private fun loadChannel() {
val url = _url;
val url = _url
if (url != null) {
setLoading(true);
_taskGetChannel.run(url);
setLoading(true)
_taskGetChannel.run(url)
}
}
private fun showChannel(channel: IPlatformChannel) {
setLoading(false);
setLoading(false)
_fragment.topBar?.onShown(channel);
_fragment.topBar?.onShown(channel)
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) {
UIDialogs.showConfirmationDialog(context, context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist).replace("{channelName}", channel.name), {
UIDialogs.showDialogProgress(context) {
_fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
StatePlaylists.instance.createPlaylistFromChannel(channel) { page ->
_fragment.lifecycleScope.launch(Dispatchers.Main) {
it.setText("${channel.name}\n" + context.getString(R.string.page) + " $page");
UIDialogs.showConfirmationDialog(context,
context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist)
.replace("{channelName}", channel.name),
{
UIDialogs.showDialogProgress(context) {
_fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
StatePlaylists.instance.createPlaylistFromChannel(channel) { page ->
_fragment.lifecycleScope.launch(Dispatchers.Main) {
it.setText("${channel.name}\n" + context.getString(R.string.page) + " $page")
}
}
};
}
catch(ex: Exception) {
Logger.e(TAG, "Error", ex);
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_convert_channel), ex);
}
} catch (ex: Exception) {
Logger.e(TAG, "Error", ex)
UIDialogs.showGeneralErrorDialog(
context,
context.getString(R.string.failed_to_convert_channel),
ex
)
}
withContext(Dispatchers.Main) {
it.hide();
withContext(Dispatchers.Main) {
it.hide()
}
}
};
};
});
});
}
})
})
_fragment.lifecycleScope.launch(Dispatchers.IO) {
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url);
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));
});
_fragment.navigate<SuggestionsFragment>(
SuggestionsFragmentData(
"", SearchType.VIDEO, channel.url
)
)
})
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons);
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
}
}
}
_buttonSubscribe.setSubscribeChannel(channel);
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
_textChannel.text = channel.name;
_textChannelSub.text = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} " + context.getString(R.string.subscribers).lowercase() else "";
_buttonSubscribe.setSubscribeChannel(channel)
_buttonSubscriptionSettings.visibility =
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
_textChannel.text = channel.name
_textChannelSub.text =
if (channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} " + context.getString(
R.string.subscribers
).lowercase() else ""
//TODO: Find a better way to access the adapter fragments..
val supportsPlaylists =
StatePlatform.instance.getChannelClient(channel.url).capabilities.hasGetChannelPlaylists
val playlistPosition = 1
if (supportsPlaylists && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(
ChannelTab.PLAYLISTS.ordinal.toLong()
)
) {
// keep the current tab selected
if (_viewPager.currentItem >= playlistPosition) {
_viewPager.setCurrentItem(_viewPager.currentItem + 1, false)
}
(_viewPager.adapter as ChannelViewPagerAdapter?)?.let {
it.getFragment<ChannelContentsFragment>().setChannel(channel);
it.getFragment<ChannelAboutFragment>().setChannel(channel);
it.getFragment<ChannelListFragment>().setChannel(channel);
it.getFragment<ChannelMonetizationFragment>().setChannel(channel);
//TODO: Call on other tabs as needed
(_viewPager.adapter as ChannelViewPagerAdapter).insert(
playlistPosition,
ChannelTab.PLAYLISTS
)
}
if (!supportsPlaylists && (_viewPager.adapter as ChannelViewPagerAdapter).containsItem(
ChannelTab.PLAYLISTS.ordinal.toLong()
)
) {
// keep the current tab selected
if (_viewPager.currentItem >= playlistPosition) {
_viewPager.setCurrentItem(_viewPager.currentItem - 1, false)
}
(_viewPager.adapter as ChannelViewPagerAdapter).remove(playlistPosition)
}
this.channel = channel;
// sets the channel for each tab
for (fragment in _fragment.childFragmentManager.fragments) {
(fragment as IChannelTabFragment).setChannel(channel)
}
(_viewPager.adapter as ChannelViewPagerAdapter).channel = channel
_viewPager.adapter!!.notifyDataSetChanged()
this.channel = channel
setPolycentricProfileOr(channel.url) {
_textChannel.text = channel.name;
_creatorThumbnail.setThumbnail(channel.thumbnail, true);
Glide.with(_imageBanner)
.load(channel.banner)
.crossfade()
.into(_imageBanner);
_textChannel.text = channel.name
_creatorThumbnail.setThumbnail(channel.thumbnail, true)
Glide.with(_imageBanner).load(channel.banner).crossfade().into(_imageBanner)
_taskLoadPolycentricProfile.run(channel.id);
};
_taskLoadPolycentricProfile.run(channel.id)
}
}
private fun setPolycentricProfileOr(url: String, or: () -> Unit) {
setPolycentricProfile(null, animate = false);
setPolycentricProfile(null, animate = false)
val cachedProfile = channel?.let { PolycentricCache.instance.getCachedProfile(url) };
val cachedProfile = channel?.let { PolycentricCache.instance.getCachedProfile(url) }
if (cachedProfile != null) {
setPolycentricProfile(cachedProfile, animate = false);
setPolycentricProfile(cachedProfile, animate = false)
} else {
or();
or()
}
}
private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
val dp_35 = 35.dp(resources)
val profile = cachedPolycentricProfile?.profile;
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_35 * dp_35)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
private fun setPolycentricProfile(
cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean
) {
val dp35 = 35.dp(resources)
val profile = cachedPolycentricProfile?.profile
val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35)?.let {
it.toURLInfoSystemLinkUrl(
profile.system.toProto(), it.process, profile.systemState.servers.toList()
)
}
val banner = profile?.systemState?.banner?.selectHighestResolutionImage()
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
if (avatar != null) {
_creatorThumbnail.setThumbnail(avatar, animate)
} else {
_creatorThumbnail.setThumbnail(channel?.thumbnail, animate)
_creatorThumbnail.setHarborAvailable(
profile != null, animate, profile?.system?.toProto()
)
}
val banner = profile?.systemState?.banner?.selectHighestResolutionImage()?.let {
it.toURLInfoSystemLinkUrl(
profile.system.toProto(), it.process, profile.systemState.servers.toList()
)
}
if (banner != null) {
Glide.with(_imageBanner)
.load(banner)
.crossfade()
.into(_imageBanner);
Glide.with(_imageBanner).load(banner).crossfade().into(_imageBanner)
} else {
Glide.with(_imageBanner)
.load(channel?.banner)
.crossfade()
.into(_imageBanner);
Glide.with(_imageBanner).load(channel?.banner).crossfade().into(_imageBanner)
}
if (profile != null) {
_fragment.topBar?.onShown(profile);
_textChannel.text = profile.systemState.username;
_fragment.topBar?.onShown(profile)
_textChannel.text = profile.systemState.username
}
(_viewPager.adapter as ChannelViewPagerAdapter?)?.let {
it.getFragment<ChannelAboutFragment>().setPolycentricProfile(profile);
it.getFragment<ChannelMonetizationFragment>().setPolycentricProfile(profile);
it.getFragment<ChannelListFragment>().setPolycentricProfile(profile);
it.getFragment<ChannelContentsFragment>().setPolycentricProfile(profile);
//TODO: Call on other tabs as needed
// sets the profile for each tab
for (fragment in _fragment.childFragmentManager.fragments) {
(fragment as IChannelTabFragment).setPolycentricProfile(profile)
}
val insertPosition = 1
//TODO only add channels and support if its setup on the polycentric profile
if (profile != null && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(
ChannelTab.SUPPORT.ordinal.toLong()
)
) {
(_viewPager.adapter as ChannelViewPagerAdapter).insert(insertPosition, ChannelTab.SUPPORT)
}
if (profile != null && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(
ChannelTab.CHANNELS.ordinal.toLong()
)
) {
(_viewPager.adapter as ChannelViewPagerAdapter).insert(insertPosition, ChannelTab.CHANNELS)
}
(_viewPager.adapter as ChannelViewPagerAdapter).profile = profile
_viewPager.adapter!!.notifyDataSetChanged()
}
}
companion object {
val TAG = "ChannelFragment";
const val TAG = "ChannelFragment"
fun newInstance() = ChannelFragment().apply { }
}
}
}
@@ -41,6 +41,7 @@ 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
@@ -264,7 +265,7 @@ class PostDetailFragment : MainFragment {
_buttonSupport.setOnClickListener {
val author = _post?.author ?: _postOverview?.author;
author?.let { _fragment.navigate<ChannelFragment>(it).selectTab(2); };
author?.let { _fragment.navigate<ChannelFragment>(it).selectTab(ChannelTab.SUPPORT); };
};
_buttonStore.setOnClickListener {
@@ -690,7 +690,7 @@ class VideoDetailView : ConstraintLayout {
_lastAudioSource = null;
_lastSubtitleSource = null;
video = null;
_playbackTracker = null;
cleanupPlaybackTracker();
Logger.i(TAG, "Keep screen on unset onClose")
fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
};
@@ -1033,7 +1033,7 @@ class VideoDetailView : ConstraintLayout {
_searchVideo = null;
video = null;
_playbackTracker = null;
cleanupPlaybackTracker();
_url = url;
_videoResumePositionMilliseconds = resumeSeconds * 1000;
_rating.visibility = View.GONE;
@@ -1071,7 +1071,7 @@ class VideoDetailView : ConstraintLayout {
}
this.video = null;
this._playbackTracker = null;
cleanupPlaybackTracker();
_searchVideo = video;
_videoResumePositionMilliseconds = resumeSeconds * 1000;
setLastPositionMilliseconds(_videoResumePositionMilliseconds, false);
@@ -1206,7 +1206,7 @@ class VideoDetailView : ConstraintLayout {
}
this.videoLocal = videoLocal;
this.video = video;
this._playbackTracker = null;
cleanupPlaybackTracker();
if(video is JSVideoDetails) {
val me = this;
@@ -1522,6 +1522,22 @@ class VideoDetailView : ConstraintLayout {
}
}
fun cleanupPlaybackTracker(){
val tracker = _playbackTracker;
if(tracker != null) {
_playbackTracker = null;
fragment.lifecycleScope.launch(Dispatchers.IO) {
Logger.i(TAG, "Cleaning up old playback tracker");
try {
tracker.onConcluded();
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to cleanup playback tracker", ex);
}
}
}
}
//Source Loads
private fun loadCurrentVideo(resumePositionMs: Long = 0) {
_didStop = false;
@@ -2016,7 +2032,7 @@ class VideoDetailView : ConstraintLayout {
private fun fetchVideo() {
Logger.i(TAG, "fetchVideo")
video = null;
_playbackTracker = null;
cleanupPlaybackTracker();
val url = _url;
if (url != null && url.isNotBlank()) {
@@ -13,6 +13,7 @@ import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlWidevineSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
@@ -44,7 +45,7 @@ class VideoHelper {
}
fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource || source is IHLSManifestSource;
fun isDownloadable(source: IAudioSource) = source is IAudioUrlSource || source is IHLSManifestAudioSource;
fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource) && source !is IAudioUrlWidevineSource
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers);
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {
@@ -22,6 +22,7 @@ import com.futo.platformplayer.api.media.models.contents.PlatformContentPlacehol
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
@@ -799,6 +800,11 @@ class StatePlatform {
return client.getChannelContents(channelUrl, type, ordering) ;
}
fun getChannelPlaylists(channelUrl: String): IPager<IPlatformPlaylist> {
val client = getChannelClient(channelUrl);
return client.getChannelPlaylists(channelUrl);
}
fun peekChannelContents(baseClient: IPlatformClient, channelUrl: String, type: String?): List<IPlatformContent> {
val client = _channelClientPool.getClientPooled(baseClient, Settings.instance.subscriptions.getSubscriptionsConcurrency());
return client.peekChannelContents(channelUrl, type) ;
@@ -5,69 +5,121 @@ import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.fragment.channel.tab.*
import com.futo.platformplayer.fragment.channel.tab.ChannelAboutFragment
import com.futo.platformplayer.fragment.channel.tab.ChannelContentsFragment
import com.futo.platformplayer.fragment.channel.tab.ChannelListFragment
import com.futo.platformplayer.fragment.channel.tab.ChannelMonetizationFragment
import com.futo.platformplayer.fragment.channel.tab.ChannelPlaylistsFragment
import com.futo.platformplayer.fragment.channel.tab.IChannelTabFragment
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.google.android.material.tabs.TabLayout
class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) : FragmentStateAdapter(fragmentManager, lifecycle) {
private val _cache: Array<Fragment?> = arrayOfNulls(4);
val onContentUrlClicked = Event2<String, ContentType>();
val onUrlClicked = Event1<String>();
val onContentClicked = Event2<IPlatformContent, Long>();
val onChannelClicked = Event1<PlatformAuthorLink>();
val onAddToClicked = Event1<IPlatformContent>();
val onAddToQueueClicked = Event1<IPlatformContent>();
val onAddToWatchLaterClicked = Event1<IPlatformContent>();
val onLongPress = Event1<IPlatformContent>();
enum class ChannelTab {
VIDEOS, CHANNELS, PLAYLISTS, SUPPORT, ABOUT
}
class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) :
FragmentStateAdapter(fragmentManager, lifecycle) {
private val _supportedFragments = mutableMapOf(
ChannelTab.VIDEOS.ordinal to ChannelTab.VIDEOS, ChannelTab.ABOUT.ordinal to ChannelTab.ABOUT
)
private val _tabs = arrayListOf(ChannelTab.VIDEOS, ChannelTab.ABOUT)
var profile: PolycentricProfile? = null
var channel: IPlatformChannel? = null
val onContentUrlClicked = Event2<String, ContentType>()
val onUrlClicked = Event1<String>()
val onContentClicked = Event2<IPlatformContent, Long>()
val onChannelClicked = Event1<PlatformAuthorLink>()
val onAddToClicked = Event1<IPlatformContent>()
val onAddToQueueClicked = Event1<IPlatformContent>()
val onAddToWatchLaterClicked = Event1<IPlatformContent>()
val onLongPress = Event1<IPlatformContent>()
override fun getItemId(position: Int): Long {
return _tabs[position].ordinal.toLong()
}
override fun containsItem(itemId: Long): Boolean {
return _supportedFragments.containsKey(itemId.toInt())
}
override fun getItemCount(): Int {
return _cache.size;
return _supportedFragments.size
}
inline fun <reified T:IChannelTabFragment> getFragment(): T {
//TODO: I have a feeling this can somehow be synced with createFragment so only 1 mapping exists (without a Map<>)
if(T::class == ChannelContentsFragment::class)
return createFragment(0) as T;
else if(T::class == ChannelListFragment::class)
return createFragment(1) as T;
//else if(T::class == ChannelStoreFragment::class)
// return createFragment(2) as T;
else if(T::class == ChannelMonetizationFragment::class)
return createFragment(2) as T;
else if(T::class == ChannelAboutFragment::class)
return createFragment(3) as T;
else
throw NotImplementedError("Implement other types");
fun getTabPosition(tab: ChannelTab): Int {
return _tabs.indexOf(tab)
}
fun getTabNames(tab: TabLayout.Tab, position: Int) {
tab.text = _tabs[position].name
}
fun insert(position: Int, tab: ChannelTab) {
_supportedFragments[tab.ordinal] = tab
_tabs.add(position, tab)
notifyItemInserted(position)
}
fun remove(position: Int) {
_supportedFragments.remove(_tabs[position].ordinal)
_tabs.removeAt(position)
notifyItemRemoved(position)
}
override fun createFragment(position: Int): Fragment {
val cachedFragment = _cache[position];
if (cachedFragment != null) {
return cachedFragment;
val fragment: Fragment
when (_tabs[position]) {
ChannelTab.VIDEOS -> {
fragment = ChannelContentsFragment.newInstance().apply {
onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit)
onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit)
onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit)
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit)
onAddToClicked.subscribe(this@ChannelViewPagerAdapter.onAddToClicked::emit)
onAddToQueueClicked.subscribe(this@ChannelViewPagerAdapter.onAddToQueueClicked::emit)
onAddToWatchLaterClicked.subscribe(this@ChannelViewPagerAdapter.onAddToWatchLaterClicked::emit)
onLongPress.subscribe(this@ChannelViewPagerAdapter.onLongPress::emit)
}
}
ChannelTab.CHANNELS -> {
fragment = ChannelListFragment.newInstance()
.apply { onClickChannel.subscribe(onChannelClicked::emit) }
}
ChannelTab.PLAYLISTS -> {
fragment = ChannelPlaylistsFragment.newInstance().apply {
onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit)
onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit)
onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit)
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit)
onAddToClicked.subscribe(this@ChannelViewPagerAdapter.onAddToClicked::emit)
onAddToQueueClicked.subscribe(this@ChannelViewPagerAdapter.onAddToQueueClicked::emit)
onAddToWatchLaterClicked.subscribe(this@ChannelViewPagerAdapter.onAddToWatchLaterClicked::emit)
onLongPress.subscribe(this@ChannelViewPagerAdapter.onLongPress::emit)
}
}
ChannelTab.SUPPORT -> {
fragment = ChannelMonetizationFragment.newInstance()
}
ChannelTab.ABOUT -> {
fragment = ChannelAboutFragment.newInstance()
}
}
channel?.let { (fragment as IChannelTabFragment).setChannel(it) }
profile?.let { (fragment as IChannelTabFragment).setPolycentricProfile(it) }
val fragment = when (position) {
0 -> ChannelContentsFragment.newInstance().apply {
onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit);
onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit);
onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit);
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit);
onAddToClicked.subscribe(this@ChannelViewPagerAdapter.onAddToClicked::emit);
onAddToQueueClicked.subscribe(this@ChannelViewPagerAdapter.onAddToQueueClicked::emit);
onAddToWatchLaterClicked.subscribe(this@ChannelViewPagerAdapter.onAddToWatchLaterClicked::emit);
onLongPress.subscribe(this@ChannelViewPagerAdapter.onLongPress::emit);
};
1 -> ChannelListFragment.newInstance().apply { onClickChannel.subscribe(onChannelClicked::emit) };
//2 -> ChannelStoreFragment.newInstance();
2 -> ChannelMonetizationFragment.newInstance();
3 -> ChannelAboutFragment.newInstance();
else -> throw IllegalStateException("Invalid tab position $position")
};
_cache[position]= fragment;
return fragment;
return fragment
}
}
@@ -106,8 +106,15 @@ class LiveChatOverlay : LinearLayout {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url);
_window?.let {
var toRemoveJS = "";
for(req in it.removeElements)
view?.evaluateJavascript("document.querySelectorAll(" + _argJsonSerializer.encodeToString(req) + ").forEach(x=>x.remove());") {};
toRemoveJS += "document.querySelectorAll(" + _argJsonSerializer.encodeToString(req) + ").forEach(x=>x.remove());\n";
view?.evaluateJavascript(toRemoveJS) {};
var toRemoveJSInterval = "";
for(req in it.removeElementsInterval)
toRemoveJSInterval += "document.querySelectorAll(" + _argJsonSerializer.encodeToString(req) + ").forEach(x=>x.remove());\n";
//Cleanup every second as fallback
view?.evaluateJavascript("setInterval(()=>{" + toRemoveJSInterval + "}, 1000)") {};
};
}
};
+3
View File
@@ -347,6 +347,7 @@
<string name="get_answers_to_common_questions">Get answers to common questions</string>
<string name="give_feedback_on_the_application">Give feedback on the application</string>
<string name="info">Info</string>
<string name="networking">Networking</string>
<string name="gesture_controls">Gesture controls</string>
<string name="volume_slider">Volume slider</string>
<string name="volume_slider_descr">Enable slide gesture to change volume</string>
@@ -461,6 +462,8 @@
<string name="deletes_all_ongoing_downloads">Deletes all ongoing downloads</string>
<string name="deletes_all_unresolved_source_files">Deletes all unresolved source files</string>
<string name="developer_mode">Developer Mode</string>
<string name="allow_all_certificates">Allow All Certificates</string>
<string name="allow_all_certificates_warning">This risks exposing all your Grayjay network traffic.</string>
<string name="development_server">Development Server</string>
<string name="experimental">Experimental</string>
<string name="cache">Cache</string>
+6
View File
@@ -30,6 +30,9 @@
<data android:host="patreon.com" />
<data android:host="soundcloud.com" />
<data android:host="twitch.tv" />
<data android:host="bilibili.com" />
<data android:host="bilibili.tv" />
<data android:host="spotify.com" />
<data android:pathPrefix="/" />
</intent-filter>
<intent-filter android:autoVerify="true">
@@ -51,6 +54,9 @@
<data android:host="patreon.com" />
<data android:host="soundcloud.com" />
<data android:host="twitch.tv" />
<data android:host="bilibili.com" />
<data android:host="bilibili.tv" />
<data android:host="spotify.com" />
</intent-filter>
</activity>
</application>
+2 -1
View File
@@ -9,7 +9,8 @@
"4a78c2ff-c20f-43ac-8f75-34515df1d320": "sources/kick/KickConfig.json",
"aac9e9f0-24b5-11ee-be56-0242ac120002": "sources/patreon/PatreonConfig.json",
"9d703ff5-c556-4962-a990-4f000829cb87": "sources/nebula/NebulaConfig.json",
"cf8ea74d-ad9b-489e-a083-539b6aa8648c": "sources/bilibili/build/BiliBiliConfig.json"
"cf8ea74d-ad9b-489e-a083-539b6aa8648c": "sources/bilibili/build/BiliBiliConfig.json",
"4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json"
},
"SOURCES_EMBEDDED_DEFAULT": [
"35ae969a-a7db-11ed-afa1-0242ac120002"
+6
View File
@@ -30,6 +30,9 @@
<data android:host="patreon.com" />
<data android:host="soundcloud.com" />
<data android:host="twitch.tv" />
<data android:host="bilibili.com" />
<data android:host="bilibili.tv" />
<data android:host="spotify.com" />
<data android:pathPrefix="/" />
</intent-filter>
<intent-filter android:autoVerify="true">
@@ -51,6 +54,9 @@
<data android:host="patreon.com" />
<data android:host="soundcloud.com" />
<data android:host="twitch.tv" />
<data android:host="bilibili.com" />
<data android:host="bilibili.tv" />
<data android:host="spotify.com" />
</intent-filter>
</activity>
</application>
+2 -1
View File
@@ -9,7 +9,8 @@
"4a78c2ff-c20f-43ac-8f75-34515df1d320": "sources/kick/KickConfig.json",
"aac9e9f0-24b5-11ee-be56-0242ac120002": "sources/patreon/PatreonConfig.json",
"9d703ff5-c556-4962-a990-4f000829cb87": "sources/nebula/NebulaConfig.json",
"cf8ea74d-ad9b-489e-a083-539b6aa8648c": "sources/bilibili/build/BiliBiliConfig.json"
"cf8ea74d-ad9b-489e-a083-539b6aa8648c": "sources/bilibili/build/BiliBiliConfig.json",
"4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json"
},
"SOURCES_EMBEDDED_DEFAULT": [
"35ae969a-a7db-11ed-afa1-0242ac120002"
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
+18 -12
View File
@@ -1,21 +1,27 @@
#!/bin/bash
# Array of directories to look in
dirs=("app/src/unstable/assets/sources" "app/src/stable/assets/sources")
# Loop through each directory
sign_scripts() {
local plugin_dir=$1
if [[ -d "$plugin_dir" ]]; then
script_file=$(find "$plugin_dir" -maxdepth 2 -name '*Script.js')
config_file=$(find "$plugin_dir" -maxdepth 2 -name '*Config.json')
sign_script="$plugin_dir/sign.sh"
if [[ -f "$sign_script" && -n "$script_file" && -n "$config_file" ]]; then
sh "$sign_script" "$script_file" "$config_file"
fi
fi
}
for dir in "${dirs[@]}"; do
if [[ -d "$dir" ]]; then # Check if directory exists
for plugin in "$dir"/*; do # Loop through each plugin folder
if [[ -d "$dir" ]]; then
for plugin in "$dir"/*; do
if [[ -d "$plugin" ]]; then
script_file=$(find "$plugin" -maxdepth 1 -name '*Script.js')
config_file=$(find "$plugin" -maxdepth 1 -name '*Config.json')
sign_script="$plugin/sign.sh"
if [[ -f "$sign_script" && -n "$script_file" && -n "$config_file" ]]; then
sh "$sign_script" "$script_file" "$config_file"
fi
sign_scripts "$plugin"
fi
done
fi
done
done