diff --git a/.gitmodules b/.gitmodules index 2e67308c..cfc1f2fa 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/LICENSE b/LICENSE deleted file mode 100644 index a3d0f019..00000000 --- a/LICENSE +++ /dev/null @@ -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. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..38414394 --- /dev/null +++ b/LICENSE.md @@ -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 Licensor’s 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. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 49076988..be7cd437 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -41,9 +41,6 @@ - diff --git a/app/src/main/assets/scripts/source.js b/app/src/main/assets/scripts/source.js index 7e619b3e..0324e0f5 100644 --- a/app/src/main/assets/scripts/source.js +++ b/app/src/main/assets/scripts/source.js @@ -436,7 +436,7 @@ class PlatformPlaylist extends PlatformContent { constructor(obj) { super(obj, 4); this.plugin_type = "PlatformPlaylist"; - this.videoCount = obj.videoCount ?? 0; + this.videoCount = obj.videoCount ?? -1; this.thumbnail = obj.thumbnail; } } diff --git a/app/src/main/java/com/futo/platformplayer/SettingsDev.kt b/app/src/main/java/com/futo/platformplayer/SettingsDev.kt index 91bba5e4..aa5e4c94 100644 --- a/app/src/main/java/com/futo/platformplayer/SettingsDev.kt +++ b/app/src/main/java/com/futo/platformplayer/SettingsDev.kt @@ -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); diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index f9565916..90902f5e 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -104,6 +104,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { lateinit var _fragMainTutorial: TutorialFragment; lateinit var _fragMainPlaylists: PlaylistsFragment; lateinit var _fragMainPlaylist: PlaylistFragment; + lateinit var _fragMainRemotePlaylist: RemotePlaylistFragment; lateinit var _fragWatchlist: WatchLaterFragment; lateinit var _fragHistory: HistoryFragment; lateinit var _fragSourceDetail: SourceDetailFragment; @@ -246,6 +247,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragMainSources = SourcesFragment.newInstance(); _fragMainPlaylists = PlaylistsFragment.newInstance(); _fragMainPlaylist = PlaylistFragment.newInstance(); + _fragMainRemotePlaylist = RemotePlaylistFragment.newInstance(); _fragPostDetail = PostDetailFragment.newInstance(); _fragWatchlist = WatchLaterFragment.newInstance(); _fragHistory = HistoryFragment.newInstance(); @@ -331,6 +333,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragMainSources.topBar = _fragTopBarAdd; _fragMainPlaylists.topBar = _fragTopBarGeneral; _fragMainPlaylist.topBar = _fragTopBarNavigation; + _fragMainRemotePlaylist.topBar = _fragTopBarNavigation; _fragPostDetail.topBar = _fragTopBarNavigation; _fragWatchlist.topBar = _fragTopBarNavigation; _fragHistory.topBar = _fragTopBarNavigation; @@ -1044,6 +1047,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { SourcesFragment::class -> _fragMainSources as T; PlaylistsFragment::class -> _fragMainPlaylists as T; PlaylistFragment::class -> _fragMainPlaylist as T; + RemotePlaylistFragment::class -> _fragMainRemotePlaylist as T; PostDetailFragment::class -> _fragPostDetail as T; WatchLaterFragment::class -> _fragWatchlist as T; HistoryFragment::class -> _fragHistory as T; diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt index 3326c068..a0a0fac1 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt @@ -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 { diff --git a/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt b/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt index 9c79b665..5121f41e 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt @@ -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( + object: X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) { } + override fun checkServerTrusted(chain: Array?, authType: String?) { } + override fun getAcceptedIssuers(): Array { + 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)); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/CachedPlatformClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/CachedPlatformClient.kt deleted file mode 100644 index c604a7e6..00000000 --- a/app/src/main/java/com/futo/platformplayer/api/media/CachedPlatformClient.kt +++ /dev/null @@ -1,109 +0,0 @@ -package com.futo.platformplayer.api.media - -import androidx.collection.LruCache -import com.futo.platformplayer.api.media.models.ResultCapabilities -import com.futo.platformplayer.api.media.models.channels.IPlatformChannel -import com.futo.platformplayer.api.media.models.chapters.IChapter -import com.futo.platformplayer.api.media.models.comments.IPlatformComment -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.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 - -/** - * A temporary class that caches video results - * In future this should be part of a bigger system - */ -class CachedPlatformClient : IPlatformClient { - private val _client : IPlatformClient; - override val id: String get() = _client.id; - override val name: String get() = _client.name; - override val icon: ImageVariable? get() = _client.icon; - - private val _cache: LruCache; - - override val capabilities: PlatformClientCapabilities - get() = _client.capabilities; - - constructor(client : IPlatformClient, cacheSize : Int = 10 * 1024 * 1024) { - this._client = client; - this._cache = LruCache(cacheSize); - } - override fun initialize() { _client.initialize() } - override fun disable() { _client.disable() } - - override fun isContentDetailsUrl(url: String): Boolean = _client.isContentDetailsUrl(url); - override fun getContentDetails(url: String): IPlatformContentDetails { - var result = _cache.get(url); - if(result == null) { - result = _client.getContentDetails(url); - _cache.put(url, result); - } - return result; - } - - override fun getContentChapters(url: String): List = _client.getContentChapters(url); - override fun getPlaybackTracker(url: String): IPlaybackTracker? = _client.getPlaybackTracker(url); - - override fun isChannelUrl(url: String): Boolean = _client.isChannelUrl(url); - override fun getChannel(channelUrl: String): IPlatformChannel = _client.getChannel(channelUrl); - - override fun getChannelCapabilities(): ResultCapabilities = _client.getChannelCapabilities(); - override fun getChannelContents( - channelUrl: String, - type: String?, - order: String?, - filters: Map>? - ): IPager = _client.getChannelContents(channelUrl); - - override fun getChannelPlaylists(channelUrl: String): IPager = _client.getChannelPlaylists(channelUrl); - - override fun getPeekChannelTypes(): List = _client.getPeekChannelTypes(); - override fun peekChannelContents(channelUrl: String, type: String?): List = _client.peekChannelContents(channelUrl, type); - - override fun getChannelUrlByClaim(claimType: Int, claimValues: Map): String? = _client.getChannelUrlByClaim(claimType, claimValues) - - override fun searchSuggestions(query: String): Array = _client.searchSuggestions(query); - override fun getSearchCapabilities(): ResultCapabilities = _client.getSearchCapabilities(); - override fun search( - query: String, - type: String?, - order: String?, - filters: Map>? - ): IPager = _client.search(query, type, order, filters); - - override fun getSearchChannelContentsCapabilities(): ResultCapabilities = _client.getSearchChannelContentsCapabilities(); - override fun searchChannelContents( - channelUrl: String, - query: String, - type: String?, - order: String?, - filters: Map>? - ): IPager = _client.searchChannelContents(channelUrl, query, type, order, filters); - - override fun searchChannels(query: String) = _client.searchChannels(query); - - override fun getComments(url: String): IPager = _client.getComments(url); - override fun getSubComments(comment: IPlatformComment): IPager = _client.getSubComments(comment); - - override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? = _client.getLiveChatWindow(url); - override fun getLiveEvents(url: String): IPager? = _client.getLiveEvents(url); - - override fun getHome(): IPager = _client.getHome(); - - override fun getUserSubscriptions(): Array { return arrayOf(); }; - - override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map>?): IPager = _client.searchPlaylists(query, type, order, filters); - override fun isPlaylistUrl(url: String): Boolean = _client.isPlaylistUrl(url); - override fun getPlaylist(url: String): IPlatformPlaylistDetails = _client.getPlaylist(url); - override fun getUserPlaylists(): Array { return arrayOf(); }; - - override fun isClaimTypeSupported(claimType: Int): Boolean { - return _client.isClaimTypeSupported(claimType); - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt index 9aaf08c4..590ecc32 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt @@ -121,6 +121,11 @@ interface IPlatformClient { */ fun getPlaybackTracker(url: String): IPlaybackTracker?; + /** + * Get content recommendations + */ + fun getContentRecommendations(url: String): IPager?; + //Comments /** diff --git a/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt index 1b76ae28..cb62b66c 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt @@ -19,7 +19,8 @@ data class PlatformClientCapabilities( val hasGetLiveChatWindow: Boolean = false, val hasGetContentChapters: Boolean = false, val hasPeekChannelContents: Boolean = false, - val hasGetChannelPlaylists: Boolean = false + val hasGetChannelPlaylists: Boolean = false, + val hasGetContentRecommendations: Boolean = false ) { } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContentDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContentDetails.kt index 781e4665..0f642764 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContentDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContentDetails.kt @@ -10,4 +10,6 @@ interface IPlatformContentDetails : IPlatformContent { fun getComments(client: IPlatformClient): IPager?; fun getPlaybackTracker(): IPlaybackTracker?; + + fun getContentRecommendations(client: IPlatformClient): IPager?; } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/playlists/IPlatformPlaylistDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/playlists/IPlatformPlaylistDetails.kt index b7783668..28947655 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/playlists/IPlatformPlaylistDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/playlists/IPlatformPlaylistDetails.kt @@ -8,5 +8,5 @@ interface IPlatformPlaylistDetails: IPlatformPlaylist { //TODO: Determine if this should be IPlatformContent (probably not?) val contents: IPager; - fun toPlaylist(): Playlist; + fun toPlaylist(onProgress: ((progress: Int) -> Unit)? = null): Playlist; } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideoDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideoDetails.kt index 39851441..1a0435d2 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideoDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideoDetails.kt @@ -7,6 +7,7 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.Thumbnails import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.ratings.IRating import com.futo.platformplayer.api.media.models.streams.sources.* @@ -56,6 +57,7 @@ open class SerializedPlatformVideoDetails( override fun getComments(client: IPlatformClient): IPager? = null; override fun getPlaybackTracker(): IPlaybackTracker? = null; + override fun getContentRecommendations(client: IPlatformClient): IPager? = null; companion object { fun fromVideo(video : IPlatformVideoDetails, subtitleSources: List) : SerializedPlatformVideoDetails { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt index aeec0ea3..0c3288fb 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt @@ -560,7 +560,7 @@ open class JSClient : IPlatformClient { plugin.executeTyped("source.getSubComments(${Json.encodeToString(comment as JSComment)})")); } - @JSDocs(16, "source.getLiveChatWindow(url)", "Gets live events for a livestream") + @JSDocs(18, "source.getLiveChatWindow(url)", "Gets live events for a livestream") @JSDocsParameter("url", "Url of live stream") override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? = isBusyWith("getLiveChatWindow") { if(!capabilities.hasGetLiveChatWindow) @@ -569,7 +569,7 @@ open class JSClient : IPlatformClient { return@isBusyWith JSLiveChatWindowDescriptor(config, plugin.executeTyped("source.getLiveChatWindow(${Json.encodeToString(url)})")); } - @JSDocs(16, "source.getLiveEvents(url)", "Gets live events for a livestream") + @JSDocs(19, "source.getLiveEvents(url)", "Gets live events for a livestream") @JSDocsParameter("url", "Url of live stream") override fun getLiveEvents(url: String): IPager? = isBusyWith("getLiveEvents") { if(!capabilities.hasGetLiveEvents) @@ -578,6 +578,20 @@ open class JSClient : IPlatformClient { return@isBusyWith JSLiveEventPager(config, this, plugin.executeTyped("source.getLiveEvents(${Json.encodeToString(url)})")); } + + + @JSDocs(19, "source.getContentRecommendations(url)", "Gets recommendations of a content page") + @JSDocsParameter("url", "Url of content") + override fun getContentRecommendations(url: String): IPager? = isBusyWith("getContentRecommendations") { + if(!capabilities.hasGetContentRecommendations) + return@isBusyWith null; + ensureEnabled(); + return@isBusyWith JSContentPager(config, this, + plugin.executeTyped("source.getContentRecommendations(${Json.encodeToString(url)})")); + } + + + @JSDocs(19, "source.searchPlaylists(query)", "Searches for playlists on the platform") @JSDocsParameter("query", "Query that search results should match") @JSDocsParameter("type", "(optional) Type of contents to get from search ") diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylist.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylist.kt index b47ae9ea..787b53e4 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylist.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylist.kt @@ -14,6 +14,6 @@ open class JSPlaylist : JSContent, IPlatformPlaylist { constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) { val contextName = "Playlist"; thumbnail = obj.getOrDefault(config, "thumbnail", contextName, null); - videoCount = obj.getOrDefault(config, "videoCount", contextName, 0)!!; + videoCount = obj.getOrDefault(config, "videoCount", contextName, -1)!!; } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylistDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylistDetails.kt index cbd4e013..f2c3935c 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylistDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaylistDetails.kt @@ -7,7 +7,7 @@ import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.structures.IPager -import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.api.media.structures.ReusablePager import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.models.Playlist @@ -15,22 +15,26 @@ class JSPlaylistDetails: JSPlaylist, IPlatformPlaylistDetails { override val contents: IPager; constructor(plugin: JSClient, config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { - contents = JSVideoPager(config, plugin, obj.getOrThrow(config, "contents", "PlaylistDetails")); + contents = ReusablePager(JSVideoPager(config, plugin, obj.getOrThrow(config, "contents", "PlaylistDetails"))); } - override fun toPlaylist(): Playlist { - val videos = contents.getResults().toMutableList(); + override fun toPlaylist(onProgress: ((progress: Int) -> Unit)?): Playlist { + val playlist = if (contents is ReusablePager) contents.getWindow() else contents; + val videos = playlist.getResults().toMutableList(); + onProgress?.invoke(videos.size); //Download all pages var allowedEmptyCount = 2; - while(contents.hasMorePages()) { - contents.nextPage(); - if(!videos.addAll(contents.getResults())) { + while(playlist.hasMorePages()) { + playlist.nextPage(); + if(!videos.addAll(playlist.getResults())) { allowedEmptyCount--; if(allowedEmptyCount <= 0) break; } else allowedEmptyCount = 2; + + onProgress?.invoke(videos.size); } return Playlist(id.toString(), name, videos.map { SerializedPlatformVideo.fromVideo(it)}); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPostDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPostDetails.kt index bc455fcc..6c80d7dc 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPostDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPostDetails.kt @@ -3,6 +3,7 @@ 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.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.post.IPlatformPost import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails @@ -18,6 +19,7 @@ import com.futo.platformplayer.states.StateDeveloper class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails { private val _hasGetComments: Boolean; + private val _hasGetContentRecommendations: Boolean; override val rating: IRating; @@ -34,6 +36,7 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails { content = obj.getOrDefault(config, "content", contextName, "") ?: ""; _hasGetComments = _content.has("getComments"); + _hasGetContentRecommendations = _content.has("getContentRecommendations"); } override fun getComments(client: IPlatformClient): IPager? { @@ -51,9 +54,27 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails { } override fun getPlaybackTracker(): IPlaybackTracker? = null; + override fun getContentRecommendations(client: IPlatformClient): IPager? { + if(!_hasGetContentRecommendations || _content.isClosed) + return null; + + if(client is DevJSClient) + return StateDeveloper.instance.handleDevCall(client.devID, "postDetail.getContentRecommendations()") { + return@handleDevCall getContentRecommendationsJS(client); + } + else if(client is JSClient) + return getContentRecommendationsJS(client); + + return null; + } + private fun getContentRecommendationsJS(client: JSClient): JSContentPager { + val contentPager = _content.invoke("getContentRecommendations", arrayOf()); + return JSContentPager(_pluginConfig, client, contentPager); + } private fun getCommentsJS(client: JSClient): JSCommentPager { val commentPager = _content.invoke("getComments", arrayOf()); return JSCommentPager(_pluginConfig, client, commentPager); } + } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoDetails.kt index 83137f23..da495498 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoDetails.kt @@ -6,6 +6,7 @@ import com.caoccao.javet.values.reference.V8ValueArray import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.ratings.IRating import com.futo.platformplayer.api.media.models.ratings.RatingLikes @@ -27,6 +28,7 @@ import com.futo.platformplayer.states.StateDeveloper class JSVideoDetails : JSVideo, IPlatformVideoDetails { private val _hasGetComments: Boolean; + private val _hasGetContentRecommendations: Boolean; private val _hasGetPlaybackTracker: Boolean; //Details @@ -66,6 +68,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails { _hasGetComments = _content.has("getComments"); _hasGetPlaybackTracker = _content.has("getPlaybackTracker"); + _hasGetContentRecommendations = _content.has("getContentRecommendations"); } override fun getPlaybackTracker(): IPlaybackTracker? { @@ -89,6 +92,24 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails { }; } + override fun getContentRecommendations(client: IPlatformClient): IPager? { + if(!_hasGetContentRecommendations || _content.isClosed) + return null; + + if(client is DevJSClient) + return StateDeveloper.instance.handleDevCall(client.devID, "videoDetail.getContentRecommendations()") { + return@handleDevCall getContentRecommendationsJS(client); + } + else if(client is JSClient) + return getContentRecommendationsJS(client); + + return null; + } + private fun getContentRecommendationsJS(client: JSClient): JSContentPager { + val contentPager = _content.invoke("getContentRecommendations", arrayOf()); + return JSContentPager(_pluginConfig, client, contentPager); + } + override fun getComments(client: IPlatformClient): IPager? { if(client !is JSClient || !_hasGetComments || _content.isClosed) return null; diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt index 81f0fb09..7c1c4e09 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt @@ -1,47 +1,37 @@ package com.futo.platformplayer.downloads import android.content.Context -import android.net.Uri -import android.os.Environment import androidx.documentfile.provider.DocumentFile -import com.arthenica.ffmpegkit.* -import com.futo.platformplayer.api.media.models.streams.sources.* -import com.futo.platformplayer.constructs.Event1 +import com.arthenica.ffmpegkit.FFmpegKit +import com.arthenica.ffmpegkit.LogCallback +import com.arthenica.ffmpegkit.ReturnCode +import com.arthenica.ffmpegkit.StatisticsCallback +import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.LocalSubtitleSource +import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.toHumanBitrate -import kotlinx.coroutines.* -import java.io.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.io.OutputStream import java.util.UUID -import java.util.concurrent.CancellationException import java.util.concurrent.Executors import kotlin.coroutines.resumeWithException @kotlinx.serialization.Serializable class VideoExport { - var state: State = State.QUEUED; - var videoLocal: VideoLocal; var videoSource: LocalVideoSource?; var audioSource: LocalAudioSource?; var subtitleSource: LocalSubtitleSource?; - var progress: Double = 0.0; - var isCancelled = false; - - var error: String? = null; - - @kotlinx.serialization.Transient - val onStateChanged = Event1(); - @kotlinx.serialization.Transient - val onProgressChanged = Event1(); - - fun changeState(newState: State) { - state = newState; - onStateChanged.emit(newState); - } - constructor(videoLocal: VideoLocal, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?) { this.videoLocal = videoLocal; this.videoSource = videoSource; @@ -50,8 +40,6 @@ class VideoExport { } suspend fun export(context: Context, onProgress: ((Double) -> Unit)? = null): DocumentFile = coroutineScope { - if(isCancelled) throw CancellationException("Export got cancelled"); - val v = videoSource; val a = audioSource; val s = subtitleSource; @@ -107,7 +95,6 @@ class VideoExport { throw Exception("Cannot export when no audio or video source is set."); } - onProgressChanged.emit(100.0); return@coroutineScope outputFile; } diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt index 3b985796..7308b1c6 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt @@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.Thumbnails import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.ratings.IRating import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor @@ -81,6 +82,8 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem { override fun getComments(client: IPlatformClient): IPager? = null; override fun getPlaybackTracker(): IPlaybackTracker? = null; + override fun getContentRecommendations(client: IPlatformClient): IPager? = null; + fun toPlatformVideo() : IPlatformVideoDetails { throw NotImplementedError(); diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt index d07720e8..44464b0d 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt @@ -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) { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelAboutFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelAboutFragment.kt index f7d9d9bf..19f1454b 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelAboutFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelAboutFragment.kt @@ -114,7 +114,7 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment { } - fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) { + override fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) { _lastPolycentricProfile = polycentricProfile; if (polycentricProfile == null) { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt index 0647afed..ea8ee4fa 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt @@ -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"); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelListFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelListFragment.kt index 00ec5982..807fbd90 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelListFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelListFragment.kt @@ -124,7 +124,7 @@ class ChannelListFragment : Fragment, IChannelTabFragment { } } - fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) { + override fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) { _taskLoadChannel.cancel(); _lastPolycentricProfile = polycentricProfile; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelMonetizationFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelMonetizationFragment.kt index e2e39ee7..53268d16 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelMonetizationFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelMonetizationFragment.kt @@ -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) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelPlaylistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelPlaylistsFragment.kt new file mode 100644 index 00000000..dbb58b68 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelPlaylistsFragment.kt @@ -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? = null + private var _pager: IPager? = null + private var _channel: IPlatformChannel? = null + private var _results: ArrayList = arrayListOf() + private var _adapterResults: InsertedViewAdapterWithLoader? = null + + val onContentClicked = Event2() + val onContentUrlClicked = Event2() + val onUrlClicked = Event1() + val onChannelClicked = Event1() + val onAddToClicked = Event1() + val onAddToQueueClicked = Event1() + val onAddToWatchLaterClicked = Event1() + val onLongPress = Event1() + + private fun getPlaylistPager(channel: IPlatformChannel): IPager { + Logger.i(TAG, "getPlaylistPager") + + return StatePlatform.instance.getChannelPlaylists(channel.url) + } + + private val _taskLoadPlaylists = + TaskHandler>({ lifecycleScope }, { + val livePager = getPlaylistPager(it) + return@TaskHandler livePager + }).success { livePager -> + setLoading(false) + + setPager(livePager) + }.exception { }.exception { + Logger.w(TAG, "Failed to load initial playlists.", it) + UIDialogs.showGeneralRetryErrorDialog(requireContext(), + it.message ?: "", + it, + { loadNextPage() }) + } + + private var _nextPageHandler: TaskHandler, List> = + TaskHandler, List>({ 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 { + 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 + ) { + 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? + if (pager is IRefreshPager<*>) { + _pagerParent = pager + pagerToSet = pager.getCurrentPager() as IPager + pager.onPagerChanged.subscribe(this) { + + lifecycleScope.launch(Dispatchers.Main) { + try { + loadPagerInternal(it as IPager) + } 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 + ) { + 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 = _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 { } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/IChannelTabFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/IChannelTabFragment.kt index e1da77c5..2b615d25 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/IChannelTabFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/IChannelTabFragment.kt @@ -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); -} \ No newline at end of file + fun setChannel(channel: IPlatformChannel) + fun setPolycentricProfile(polycentricProfile: PolycentricProfile?) { + + } +} diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt index 3359119f..ad4aa84d 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt @@ -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); +data class PolycentricProfile( + val system: PublicKey, val systemState: SystemState, val ownedClaims: List +) 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; - private val _taskGetChannel: TaskHandler; + private val _taskLoadPolycentricProfile: TaskHandler + private val _taskGetChannel: TaskHandler - constructor(fragment: ChannelFragment, inflater: LayoutInflater) : super(inflater.context) { - _fragment = fragment; - inflater.inflate(R.layout.fragment_channel, this); - - _taskLoadPolycentricProfile = TaskHandler({fragment.lifecycleScope}, { id -> - return@TaskHandler PolycentricCache.instance.getProfileAsync(id); - }) - .success { it -> setPolycentricProfile(it, animate = true) } - .exception { - Logger.w(TAG, "Failed to load polycentric profile.", it); - }; - - _taskGetChannel = TaskHandler({fragment.lifecycleScope}, { url -> StatePlatform.instance.getChannelLive(url) }) - .success { showChannel(it); } + init { + inflater.inflate(R.layout.fragment_channel, this) + _taskLoadPolycentricProfile = + TaskHandler({ fragment.lifecycleScope }, + { id -> + return@TaskHandler PolycentricCache.instance.getProfileAsync(id) + }).success { setPolycentricProfile(it, animate = true) }.exception { + Logger.w(TAG, "Failed to load polycentric profile.", it) + } + _taskGetChannel = TaskHandler({ fragment.lifecycleScope }, + { url -> StatePlatform.instance.getChannelLive(url) }).success { showChannel(it); } .exception { - 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 { + Logger.e(TAG, "Failed to load channel.", it) + UIDialogs.showGeneralRetryErrorDialog( + context, it.message ?: "", it, { loadChannel() }, null, fragment + ) } - .exception { - 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(c) } adapter.onContentClicked.subscribe { v, _ -> - if(v is IPlatformVideo) { - StatePlayer.instance.clearQueue(); - fragment.navigate(v).maximizeVideoDetail(); - } else if (v is IPlatformPlaylist) { - fragment.navigate(v); - } else if (v is IPlatformPost) { - fragment.navigate(v); + when (v) { + is IPlatformVideo -> { + StatePlayer.instance.clearQueue() + fragment.navigate(v).maximizeVideoDetail() + } + + is IPlatformPlaylist -> { + fragment.navigate(v) + } + + is IPlatformPost -> { + fragment.navigate(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(url); + fragment.navigate(url) } adapter.onContentUrlClicked.subscribe { url, contentType -> - when(contentType) { + when (contentType) { ContentType.MEDIA -> { - StatePlayer.instance.clearQueue(); - fragment.navigate(url).maximizeVideoDetail(); - }; - ContentType.URL -> fragment.navigate(url); - else -> {}; + StatePlayer.instance.clearQueue() + fragment.navigate(url).maximizeVideoDetail() + } + + ContentType.URL -> fragment.navigate(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(SuggestionsFragmentData("", SearchType.VIDEO, channel.url)); - }); + _fragment.navigate( + SuggestionsFragmentData( + "", SearchType.VIDEO, channel.url + ) + ) + }) - _fragment.topBar?.assume()?.setMenuItems(buttons); + _fragment.topBar?.assume()?.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().setChannel(channel); - it.getFragment().setChannel(channel); - it.getFragment().setChannel(channel); - it.getFragment().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().setPolycentricProfile(profile); - it.getFragment().setPolycentricProfile(profile); - it.getFragment().setPolycentricProfile(profile); - it.getFragment().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 { } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt index cde70f32..fcabcb5b 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt @@ -6,28 +6,32 @@ import android.view.LayoutInflater import android.widget.LinearLayout import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import com.futo.platformplayer.* +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.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.structures.* +import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.video.PlayerManager import com.futo.platformplayer.views.FeedStyle -import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewHolder +import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter import com.futo.platformplayer.views.adapters.feedtypes.PreviewNestedVideoViewHolder import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoViewHolder import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay +import com.futo.platformplayer.withTimestamp import kotlin.math.floor abstract class ContentFeedView : FeedView, ContentPreviewViewHolder> where TFragment : MainFragment { @@ -183,7 +187,7 @@ abstract class ContentFeedView : FeedView(content).maximizeVideoDetail(); } } else if (content is IPlatformPlaylist) { - fragment.navigate(content); + fragment.navigate(content); } else if (content is IPlatformPost) { fragment.navigate(content); } @@ -194,7 +198,7 @@ abstract class ContentFeedView : FeedView(url).maximizeVideoDetail(); }; - ContentType.PLAYLIST -> fragment.navigate(url); + ContentType.PLAYLIST -> fragment.navigate(url); ContentType.URL -> fragment.navigate(url); else -> {}; } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt index f7a34777..076c0542 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt @@ -156,7 +156,7 @@ class ContentSearchResultsFragment : MainFragment() { onSearch.subscribe(this) { if(it.isHttpUrl()) { if(StatePlatform.instance.hasEnabledPlaylistClient(it)) - navigate(it); + navigate(it); else if(StatePlatform.instance.hasEnabledChannelClient(it)) navigate(it); else diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt index ae584d40..93b5e7e9 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt @@ -8,7 +8,7 @@ import android.widget.LinearLayout import android.widget.TextView import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView -import com.futo.platformplayer.* +import com.futo.platformplayer.R import com.futo.platformplayer.downloads.VideoDownload import com.futo.platformplayer.downloads.VideoLocal import com.futo.platformplayer.logging.Logger @@ -16,12 +16,13 @@ import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.toHumanBytesSize import com.futo.platformplayer.views.AnyInsertedAdapterView import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop -import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.adapters.viewholders.VideoDownloadViewHolder import com.futo.platformplayer.views.items.ActiveDownloadItem import com.futo.platformplayer.views.items.PlaylistDownloadItem +import com.futo.platformplayer.views.others.ProgressBar import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -64,16 +65,6 @@ class DownloadsFragment : MainFragment() { } } }; - StateDownloads.instance.onExportsChanged.subscribe(this) { - lifecycleScope.launch(Dispatchers.Main) { - try { - Logger.i(TAG, "Reloading UI for exports"); - _view?.reloadUI() - } catch (e: Throwable) { - Logger.e(TAG, "Failed to reload UI for exports", e) - } - } - }; } override fun onPause() { @@ -81,7 +72,6 @@ class DownloadsFragment : MainFragment() { StateDownloads.instance.onDownloadsChanged.remove(this); StateDownloads.instance.onDownloadedChanged.remove(this); - StateDownloads.instance.onExportsChanged.remove(this); } private class DownloadsView : LinearLayout { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ImportPlaylistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ImportPlaylistsFragment.kt index e90a535d..fbfe75c8 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ImportPlaylistsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ImportPlaylistsFragment.kt @@ -12,16 +12,21 @@ import android.widget.TextView import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.views.AnyAdapterView import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny import com.futo.platformplayer.views.adapters.viewholders.ImportPlaylistsViewHolder import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class ImportPlaylistsFragment : MainFragment() { override val isMainView : Boolean = true; @@ -67,7 +72,7 @@ class ImportPlaylistsFragment : MainFragment() { private val _items: ArrayList = arrayListOf(); private var _currentLoadIndex = 0; - private var _taskLoadPlaylist: TaskHandler; + private var _taskLoadPlaylist: TaskHandler; constructor(fragment: ImportPlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) { _fragment = fragment; @@ -102,7 +107,7 @@ class ImportPlaylistsFragment : MainFragment() { setLoading(false); - _taskLoadPlaylist = TaskHandler({fragment.lifecycleScope}, { link -> StatePlatform.instance.getPlaylist(link).toPlaylist(); }) + _taskLoadPlaylist = TaskHandler({fragment.lifecycleScope}, { link -> StatePlatform.instance.getPlaylist(link); }) .success { if (it != null) { _items.add(SelectablePlaylist(it)); @@ -113,7 +118,7 @@ class ImportPlaylistsFragment : MainFragment() { }.exceptionWithParameter { ex, para -> //setLoading(false); Logger.w(ChannelFragment.TAG, "Failed to load results.", ex); - UIDialogs.toast(context, context.getString(R.string.failed_to_fetch) + "\n${para}", false) + UIDialogs.appToast(context.getString(R.string.failed_to_fetch) + "\n${para}\n" + ex.message, false) //UIDialogs.showDataRetryDialog(layoutInflater, { load(); }); loadNext(); }; @@ -147,12 +152,32 @@ class ImportPlaylistsFragment : MainFragment() { it.title = context.getString(R.string.import_playlists); it.onImport.subscribe(this) { val playlistsToImport = _items.filter { i -> i.selected }.toList(); - for (playlistToImport in playlistsToImport) { - StatePlaylists.instance.createOrUpdatePlaylist(playlistToImport.playlist); - } - UIDialogs.toast("${playlistsToImport.size} " + context.getString(R.string.playlists_imported)); - _fragment.closeSegment(); + UIDialogs.showDialogProgress(context) { + it.setText("Importing playlists.."); + it.setProgress(0f); + _fragment.lifecycleScope.launch(Dispatchers.IO) { + for ((i, playlistToImport) in playlistsToImport.withIndex()) { + withContext(Dispatchers.Main) { + it.setText("Importing playlists..\n[${playlistToImport.playlist.name}]"); + } + try { + StatePlaylists.instance.createOrUpdatePlaylist(playlistToImport.playlist.toPlaylist()); + } + catch(ex: Throwable) { + UIDialogs.appToast("Failed to import [${playlistToImport.playlist.name}]\n" + ex.message); + } + withContext(Dispatchers.Main) { + it.setProgress(i.toDouble() / playlistsToImport.size); + } + } + withContext(Dispatchers.Main) { + UIDialogs.toast("${playlistsToImport.size} " + context.getString(R.string.playlists_imported)); + _fragment.closeSegment(); + it.dismiss(); + } + } + } }; } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt index d938e970..af128741 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt @@ -1,14 +1,11 @@ package com.futo.platformplayer.fragment.mainactivity.main import android.annotation.SuppressLint -import android.graphics.drawable.Animatable import android.os.Bundle -import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.app.ShareCompat -import androidx.core.view.setPadding import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.* import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist @@ -30,7 +27,6 @@ import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class PlaylistFragment : MainFragment() { override val isMainView : Boolean = true; @@ -70,7 +66,6 @@ class PlaylistFragment : MainFragment() { private val _fragment: PlaylistFragment; private var _playlist: Playlist? = null; - private var _remotePlaylist: IPlatformPlaylistDetails? = null; private var _editPlaylistNameInput: SlideUpMenuTextInput? = null; private var _editPlaylistOverlay: SlideUpMenuOverlay? = null; private var _url: String? = null; @@ -136,12 +131,11 @@ class PlaylistFragment : MainFragment() { return@TaskHandler StatePlatform.instance.getPlaylist(it); }) .success { - setLoading(false); - _remotePlaylist = it; setName(it.name); - setVideos(it.contents.getResults(), false); - setVideoCount(it.videoCount); //TODO: Implement support for pagination + setVideos(it.toPlaylist().videos, false); + setVideoCount(it.videoCount); + setLoading(false); } .exception { Logger.w(TAG, "Failed to load playlist.", it); @@ -151,58 +145,62 @@ class PlaylistFragment : MainFragment() { } fun onShown(parameter: Any?) { - _taskLoadPlaylist.cancel(); + _taskLoadPlaylist.cancel() if (parameter is Playlist?) { - _playlist = parameter; - _remotePlaylist = null; - _url = null; + _playlist = parameter + _url = null - if(parameter != null) { - setName(parameter.name); - setVideos(parameter.videos, true); - setVideoCount(parameter.videos.size); - setButtonDownloadVisible(true); - setButtonEditVisible(true); + if (parameter != null) { + setName(parameter.name) + setVideos(parameter.videos, true) + setVideoCount(parameter.videos.size) + setButtonDownloadVisible(true) + setButtonEditVisible(true) + + if (!StatePlaylists.instance.playlistStore.getItems().contains(parameter)) { + _fragment.topBar?.assume() + ?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) { + StatePlaylists.instance.playlistStore.save(parameter) + _fragment.topBar?.assume()?.setMenuItems( + arrayListOf() + ) + UIDialogs.toast("Playlist saved") + })) + } } else { - setName(null); - setVideos(null, false); - setVideoCount(-1); - setButtonDownloadVisible(false); - setButtonEditVisible(false); + setName(null) + setVideos(null, false) + setVideoCount(-1) + setButtonDownloadVisible(false) + setButtonEditVisible(false) } - - //TODO: Do I have to remove the showConvertPlaylistButton(); button here? } else if (parameter is IPlatformPlaylist) { - _playlist = null; - _remotePlaylist = null; - _url = parameter.url; + _playlist = null + _url = parameter.url - setVideoCount(parameter.videoCount); - setName(parameter.name); - setVideos(null, false); - setButtonDownloadVisible(false); - setButtonEditVisible(false); + setVideoCount(parameter.videoCount) + setName(parameter.name) + setVideos(null, false) + setButtonDownloadVisible(false) + setButtonEditVisible(false) - fetchPlaylist(); - showConvertPlaylistButton(); + fetchPlaylist() } else if (parameter is String) { - _playlist = null; - _remotePlaylist = null; - _url = parameter; + _playlist = null + _url = parameter - setName(null); - setVideos(null, false); - setVideoCount(-1); - setButtonDownloadVisible(false); - setButtonEditVisible(false); + setName(null) + setVideos(null, false) + setVideoCount(-1) + setButtonDownloadVisible(false) + setButtonEditVisible(false) - fetchPlaylist(); - showConvertPlaylistButton(); + fetchPlaylist() } _playlist?.let { - updateDownloadState(VideoDownload.GROUP_PLAYLIST, it.id, this::download); + updateDownloadState(VideoDownload.GROUP_PLAYLIST, it.id, this::download) } } @@ -242,34 +240,6 @@ class PlaylistFragment : MainFragment() { StateDownloads.instance.onDownloadedChanged.remove(this); } - private fun showConvertPlaylistButton() { - _fragment.topBar?.assume()?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) { - val remotePlaylist = _remotePlaylist; - if (remotePlaylist == null) { - UIDialogs.toast(context.getString(R.string.please_wait_for_playlist_to_finish_loading)); - return@Pair; - } - - setLoading(true); - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { - try { - StatePlaylists.instance.playlistStore.save(remotePlaylist.toPlaylist()); - - withContext(Dispatchers.Main) { - setLoading(false); - UIDialogs.toast(context.getString(R.string.playlist_copied_as_local_playlist)); - } - } catch (e: Throwable) { - withContext(Dispatchers.Main) { - setLoading(false); - } - - throw e; - } - } - })); - } - private fun fetchPlaylist() { Logger.i(TAG, "fetchPlaylist") @@ -290,21 +260,15 @@ class PlaylistFragment : MainFragment() { override fun onPlayAllClick() { val playlist = _playlist; - val remotePlaylist = _remotePlaylist; if (playlist != null) { StatePlayer.instance.setPlaylist(playlist, focus = true); - } else if (remotePlaylist != null) { - StatePlayer.instance.setPlaylist(remotePlaylist, focus = true, shuffle = false); } } override fun onShuffleClick() { val playlist = _playlist; - val remotePlaylist = _remotePlaylist; if (playlist != null) { StatePlayer.instance.setPlaylist(playlist, focus = true, shuffle = true); - } else if (remotePlaylist != null) { - StatePlayer.instance.setPlaylist(remotePlaylist, focus = true, shuffle = true); } } @@ -320,19 +284,12 @@ class PlaylistFragment : MainFragment() { } override fun onVideoClicked(video: IPlatformVideo) { val playlist = _playlist; - val remotePlaylist = _remotePlaylist; if (playlist != null) { val index = playlist.videos.indexOf(video); if (index == -1) return; StatePlayer.instance.setPlaylist(playlist, index, true); - } else if (remotePlaylist != null) { - val index = remotePlaylist.contents.getResults().indexOf(video); - if (index == -1) - return; - - StatePlayer.instance.setPlaylist(remotePlaylist, index, true); } } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt index 22ad5e93..df09b741 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt @@ -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(it).selectTab(2); }; + author?.let { _fragment.navigate(it).selectTab(ChannelTab.SUPPORT); }; }; _buttonStore.setOnClickListener { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt new file mode 100644 index 00000000..349b4fb0 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt @@ -0,0 +1,408 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.annotation.SuppressLint +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.app.ShareCompat +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.futo.platformplayer.* +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.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.MultiPager +import com.futo.platformplayer.api.media.structures.ReusablePager +import com.futo.platformplayer.constructs.TaskHandler +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.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import com.futo.platformplayer.views.adapters.VideoListEditorViewHolder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +enum class Action { + PLAY_ALL, SHUFFLE, PLAY, NONE +} + +class RemotePlaylistFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _view: RemotePlaylistView? = null; + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + _view?.onShown(parameter); + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = RemotePlaylistView(this, inflater); + _view = view; + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _view = null; + } + + @SuppressLint("ViewConstructor") + class RemotePlaylistView : LinearLayout { + private val _fragment: RemotePlaylistFragment; + + private var _remotePlaylist: IPlatformPlaylistDetails? = null; + private var _remotePlaylistPagerWindow: IPager? = null; + private var _url: String? = null; + private val _videos: ArrayList = arrayListOf(); + + private val _taskLoadPlaylist: TaskHandler; + private var _nextPageHandler: TaskHandler, List>; + + private var _imagePlaylistThumbnail: ImageView; + private var _textName: TextView; + private var _textMetadata: TextView; + private var _loaderOverlay: FrameLayout; + private var _imageLoader: ImageView; + private var _overlayContainer: FrameLayout; + private var _buttonShare: ImageButton; + private var _recyclerPlaylist: RecyclerView; + private var _llmPlaylist: LinearLayoutManager; + private val _adapterVideos: InsertedViewAdapterWithLoader; + private val _scrollListener: RecyclerView.OnScrollListener + + + + constructor(fragment: RemotePlaylistFragment, inflater: LayoutInflater) : super(inflater.context) { + inflater.inflate(R.layout.fragment_remote_playlist, this); + + _fragment = fragment; + + _textName = findViewById(R.id.text_name); + _textMetadata = findViewById(R.id.text_metadata); + _imagePlaylistThumbnail = findViewById(R.id.image_playlist_thumbnail); + _loaderOverlay = findViewById(R.id.layout_loading_overlay); + _imageLoader = findViewById(R.id.image_loader); + _recyclerPlaylist = findViewById(R.id.recycler_playlist); + _llmPlaylist = LinearLayoutManager(context); + _adapterVideos = InsertedViewAdapterWithLoader(context, + arrayListOf(), + arrayListOf(), + childCountGetter = { _videos.size }, + childViewHolderBinder = { viewHolder, position -> + viewHolder.bind( + _videos[position], + false + ) + }, + childViewHolderFactory = { viewGroup, _ -> + val view = LayoutInflater.from(viewGroup.context) + .inflate(R.layout.list_playlist, viewGroup, false) + val holder = VideoListEditorViewHolder(view, null) + holder.onClick.subscribe { + convertPlaylist(false, Action.PLAY, holder.video) + } + return@InsertedViewAdapterWithLoader holder + }) + + _recyclerPlaylist.adapter = _adapterVideos; + _recyclerPlaylist.layoutManager = _llmPlaylist; + + _overlayContainer = findViewById(R.id.overlay_container); + val buttonPlayAll = findViewById(R.id.button_play_all); + val buttonShuffle = findViewById(R.id.button_shuffle); + + _buttonShare = findViewById(R.id.button_share); + _buttonShare.setOnClickListener { + val remotePlaylist = _remotePlaylist ?: return@setOnClickListener; + + _fragment.startActivity(ShareCompat.IntentBuilder(context) + .setType("text/plain") + .setText(remotePlaylist.shareUrl) + .intent); + }; + + buttonPlayAll.setOnClickListener { + convertPlaylist(false, Action.PLAY_ALL); + }; + buttonShuffle.setOnClickListener { + convertPlaylist(false, Action.SHUFFLE); + }; + + _taskLoadPlaylist = TaskHandler( + StateApp.instance.scopeGetter, + { + return@TaskHandler StatePlatform.instance.getPlaylist(it); + }) + .success { + _remotePlaylist = it; + val c = it.contents; + _remotePlaylistPagerWindow = if (c is ReusablePager) c.getWindow() else c; + setName(it.name); + setVideos(_remotePlaylistPagerWindow!!.getResults()); + setVideoCount(it.videoCount); + setLoading(false); + } + .exception { + Logger.w(TAG, "Failed to load playlist.", it); + val c = context ?: return@exception; + UIDialogs.showGeneralRetryErrorDialog(c, context.getString(R.string.failed_to_load_playlist), it, ::fetchPlaylist, null, fragment); + }; + + _nextPageHandler = TaskHandler, List>({fragment.lifecycleScope}, { + if (it is IAsyncPager<*>) + it.nextPageAsync(); + else + it.nextPage(); + + processPagerExceptions(it); + return@TaskHandler it.getResults(); + }).success { + _adapterVideos.setLoading(false); + addVideos(it); + //TODO: ensureEnoughContentVisible() + }.exception { + Logger.w(TAG, "Failed to load next page.", it); + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, { + loadNextPage(); + }, null, fragment); + }; + + _scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + + val visibleItemCount = _recyclerPlaylist.childCount + val firstVisibleItem = _llmPlaylist.findFirstVisibleItemPosition() + val visibleThreshold = 15 + if (!_adapterVideos.isLoading && firstVisibleItem + visibleItemCount + visibleThreshold >= _videos.size) { + loadNextPage() + } + } + } + + _recyclerPlaylist.addOnScrollListener(_scrollListener) + } + + private fun loadNextPage() { + val pager: IPager = _remotePlaylistPagerWindow ?: return; + val hasMorePages = pager.hasMorePages(); + Logger.i(TAG, "loadNextPage() hasMorePages=$hasMorePages, page size=${pager.getResults().size}"); + + if (pager.hasMorePages()) { + _adapterVideos.setLoading(true); + _nextPageHandler.run(pager); + } + } + + private fun processPagerExceptions(pager: IPager<*>) { + if(pager is MultiPager<*> && pager.allowFailure) { + val ex = pager.getResultExceptions(); + for(kv in ex) { + val jsVideoPager: JSPager<*>? = if(kv.key is MultiPager<*>) + (kv.key as MultiPager<*>).findPager { it is JSPager<*> } as JSPager<*>?; + else if(kv.key is JSPager<*>) + kv.key as JSPager<*>; + else null; + + context?.let { + _fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + if(jsVideoPager != null) + UIDialogs.toast(it, context.getString(R.string.plugin_pluginname_failed_message).replace("{pluginName}", jsVideoPager.getPluginConfig().name).replace("{message}", kv.value.message ?: ""), false); + else + UIDialogs.toast(it, kv.value.message ?: "", false); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to show toast.", e) + } + } + } + } + } + } + + fun onShown(parameter: Any?) { + _taskLoadPlaylist.cancel(); + _nextPageHandler.cancel(); + + if (parameter is IPlatformPlaylist) { + _remotePlaylist = null; + _url = parameter.url; + + setVideoCount(parameter.videoCount); + setName(parameter.name); + setVideos(null); + + fetchPlaylist(); + showConvertPlaylistButton(); + } else if (parameter is String) { + _remotePlaylist = null; + _url = parameter; + + setName(null); + setVideos(null); + setVideoCount(-1); + + fetchPlaylist(); + showConvertPlaylistButton(); + } + } + + private fun convertPlaylist( + savePlaylist: Boolean, action: Action, video: IPlatformVideo? = null + ) { + val remotePlaylist = _remotePlaylist + if (remotePlaylist == null) { + UIDialogs.toast(context.getString(R.string.please_wait_for_playlist_to_finish_loading)) + return + } + + val convert = { + setLoading(true) + + UIDialogs.showDialogProgress(context) { + it.setText("Converting playlist..") + it.setProgress(0f) + + _fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + val playlist = remotePlaylist.toPlaylist { progress -> + _fragment.lifecycleScope.launch(Dispatchers.Main) { + it.setProgress(progress.toDouble() / remotePlaylist.videoCount) + } + } + + if (savePlaylist) { + StatePlaylists.instance.playlistStore.save(playlist) + } + + _fragment.lifecycleScope.launch(Dispatchers.Main) { + UIDialogs.toast("Playlist converted") + it.dismiss() + _fragment.navigate(playlist) + when (action) { + Action.SHUFFLE -> StatePlayer.instance.setPlaylist( + playlist, focus = true, shuffle = true + ) + + Action.PLAY_ALL -> StatePlayer.instance.setPlaylist( + playlist, focus = true + ) + + Action.PLAY -> { + StatePlayer.instance.setPlaylist( + playlist, _videos.indexOf(video), true + ) + } + + Action.NONE -> {} + } + } + } catch (ex: Throwable) { + UIDialogs.appToast("Failed to convert playlist.\n" + ex.message) + } + } + } + } + + if (remotePlaylist.videoCount > 100) { + val c = context ?: return + UIDialogs.showConfirmationDialog( + c, "Conversion to local playlist is required for this action", convert + ) + } else { + convert() + } + } + + private fun showConvertPlaylistButton() { + _fragment.topBar?.assume()?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) { + convertPlaylist(true, Action.NONE); + })); + } + + private fun fetchPlaylist() { + Logger.i(TAG, "fetchPlaylist") + + val url = _url; + if (!url.isNullOrBlank()) { + setLoading(true); + _taskLoadPlaylist.run(url); + } + } + + private fun setName(name: String?) { + _textName.text = name ?: ""; + } + + private fun setVideoCount(videoCount: Int = -1) { + _textMetadata.text = if (videoCount == -1) "" else "${videoCount} " + context.getString(R.string.videos); + } + + private fun setVideos(videos: List?) { + if (!videos.isNullOrEmpty()) { + val video = videos.first(); + _imagePlaylistThumbnail.let { + Glide.with(it) + .load(video.thumbnails.getHQThumbnail()) + .placeholder(R.drawable.placeholder_video_thumbnail) + .crossfade() + .into(it); + }; + } else { + _textMetadata.text = "0 " + context.getString(R.string.videos); + Glide.with(_imagePlaylistThumbnail) + .load(R.drawable.placeholder_video_thumbnail) + .into(_imagePlaylistThumbnail) + } + + synchronized(_videos) { + _videos.clear(); + _videos.addAll(videos ?: listOf()); + _adapterVideos.notifyDataSetChanged(); + } + } + + private fun addVideos(videos: List) { + synchronized(_videos) { + val index = _videos.size; + _videos.addAll(videos); + _adapterVideos.notifyItemRangeInserted(_adapterVideos.childToParentPosition(index), videos.size); + } + } + + private fun setLoading(isLoading: Boolean) { + if (isLoading){ + (_imageLoader.drawable as Animatable?)?.start() + _loaderOverlay.visibility = View.VISIBLE; + } + else { + _loaderOverlay.visibility = View.GONE; + (_imageLoader.drawable as Animatable?)?.stop() + } + } + } + + companion object { + private const val TAG = "RemotePlaylistFragment"; + fun newInstance() = RemotePlaylistFragment().apply {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt index 20cdcaa2..5139c0f8 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt @@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.models.Thumbnail import com.futo.platformplayer.api.media.models.Thumbnails import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.ratings.IRating import com.futo.platformplayer.api.media.models.ratings.RatingLikes @@ -144,10 +145,8 @@ class TutorialFragment : MainFragment() { override fun getComments(client: IPlatformClient): IPager { return EmptyPager() } - - override fun getPlaybackTracker(): IPlaybackTracker? { - return null - } + override fun getPlaybackTracker(): IPlaybackTracker? = null; + override fun getContentRecommendations(client: IPlatformClient): IPager? = null; } companion object { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 07ba3e7f..6d81df24 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -1063,6 +1063,11 @@ class VideoDetailView : ConstraintLayout { if(!bypassSameVideoCheck && this.video?.url == video.url) return; + //Loop workaround + if(bypassSameVideoCheck && this.video?.url == video.url && StatePlayer.instance.loopVideo) { + _player.seekTo(0); + return; + } val cachedVideo = StateDownloads.instance.getCachedVideo(video.id); if(cachedVideo != null) { diff --git a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt index 9ca4aa8e..df4cf4eb 100644 --- a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt +++ b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt @@ -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) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers); fun selectBestVideoSource(sources: Iterable, desiredPixelCount : Int, prefContainers : Array) : IVideoSource? { diff --git a/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt b/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt index 70808ba2..02a39160 100644 --- a/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt +++ b/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt @@ -2,6 +2,7 @@ package com.futo.platformplayer.serializers import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformLockedContent import com.futo.platformplayer.api.media.models.video.SerializedPlatformNestedContent import com.futo.platformplayer.api.media.models.video.SerializedPlatformPost import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo @@ -30,6 +31,7 @@ class PlatformContentSerializer : JsonContentPolymorphicSerializer SerializedPlatformNestedContent.serializer(); "ARTICLE" -> throw NotImplementedError("Articles not yet implemented"); "POST" -> SerializedPlatformPost.serializer(); + "LOCKED" -> SerializedPlatformLockedContent.serializer(); else -> throw NotImplementedError("Unknown Content Type Value: ${obj?.jsonPrimitive?.contentOrNull}") }; } else { @@ -38,6 +40,7 @@ class PlatformContentSerializer : JsonContentPolymorphicSerializer SerializedPlatformNestedContent.serializer(); ContentType.ARTICLE.value -> throw NotImplementedError("Articles not yet implemented"); ContentType.POST.value -> SerializedPlatformPost.serializer(); + ContentType.LOCKED.value -> SerializedPlatformLockedContent.serializer(); else -> throw NotImplementedError("Unknown Content Type Value: ${obj.jsonPrimitive.int}") }; } diff --git a/app/src/main/java/com/futo/platformplayer/services/ExportingService.kt b/app/src/main/java/com/futo/platformplayer/services/ExportingService.kt deleted file mode 100644 index a447f70c..00000000 --- a/app/src/main/java/com/futo/platformplayer/services/ExportingService.kt +++ /dev/null @@ -1,236 +0,0 @@ -package com.futo.platformplayer.services - -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.content.pm.ServiceInfo -import android.os.Build -import android.os.IBinder -import androidx.core.app.NotificationCompat -import com.futo.platformplayer.R -import com.futo.platformplayer.activities.MainActivity -import com.futo.platformplayer.api.http.ManagedHttpClient -import com.futo.platformplayer.downloads.VideoExport -import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.share -import com.futo.platformplayer.states.Announcement -import com.futo.platformplayer.states.AnnouncementType -import com.futo.platformplayer.states.StateAnnouncement -import com.futo.platformplayer.states.StateDownloads -import com.futo.platformplayer.stores.FragmentedStorage -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.time.OffsetDateTime -import java.util.UUID - - -class ExportingService : Service() { - private val TAG = "ExportingService"; - - private val EXPORT_NOTIF_ID = 4; - private val EXPORT_NOTIF_TAG = "export"; - private val EXPORT_NOTIF_CHANNEL_ID = "exportChannel"; - private val EXPORT_NOTIF_CHANNEL_NAME = "Export"; - - //Context - private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default); - private var _notificationManager: NotificationManager? = null; - private var _notificationChannel: NotificationChannel? = null; - - private val _client = ManagedHttpClient(); - - private var _started = false; - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Logger.i(TAG, "onStartCommand"); - - synchronized(this) { - if(_started) - return START_STICKY; - - if(!FragmentedStorage.isInitialized) { - closeExportSession(); - return START_NOT_STICKY; - } - - _started = true; - } - setupNotificationRequirements(); - - _callOnStarted?.invoke(this); - _instance = this; - - _scope.launch { - try { - doExporting(); - } - catch(ex: Throwable) { - try { - StateAnnouncement.instance.registerAnnouncementSession( - Announcement( - "rootExportException", - "An root export service exception happened", - ex.message ?: "", - AnnouncementType.SESSION, - OffsetDateTime.now() - ) - ); - } catch(_: Throwable){} - } - }; - - return START_STICKY; - } - fun setupNotificationRequirements() { - _notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager; - _notificationChannel = NotificationChannel(EXPORT_NOTIF_CHANNEL_ID, EXPORT_NOTIF_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply { - this.enableVibration(false); - this.setSound(null, null); - }; - _notificationManager!!.createNotificationChannel(_notificationChannel!!); - } - - override fun onCreate() { - Logger.i(TAG, "onCreate"); - super.onCreate() - } - - override fun onBind(p0: Intent?): IBinder? { - return null; - } - - private suspend fun doExporting() { - Logger.i(TAG, "doExporting - Starting Exports"); - val ignore = mutableListOf(); - var currentExport: VideoExport? = StateDownloads.instance.getExporting().firstOrNull(); - while (currentExport != null) - { - try{ - notifyExport(currentExport); - doExport(applicationContext, currentExport); - } - catch(ex: Throwable) { - Logger.e(TAG, "Failed export [${currentExport.videoLocal.name}]: ${ex.message}", ex); - currentExport.error = ex.message; - currentExport.changeState(VideoExport.State.ERROR); - ignore.add(currentExport); - - //Give it a sec - Thread.sleep(500); - } - - currentExport = StateDownloads.instance.getExporting().filter { !ignore.contains(it) }.firstOrNull(); - } - Logger.i(TAG, "doExporting - Ending Exports"); - stopService(this); - } - - private suspend fun doExport(context: Context, export: VideoExport) { - Logger.i(TAG, "Exporting [${export.videoLocal.name}] started"); - - export.changeState(VideoExport.State.EXPORTING); - - var lastNotifyTime: Long = 0L; - val file = export.export(context) { progress -> - export.progress = progress; - - val currentTime = System.currentTimeMillis(); - if (currentTime - lastNotifyTime > 500) { - notifyExport(export); - lastNotifyTime = currentTime; - } - } - export.changeState(VideoExport.State.COMPLETED); - Logger.i(TAG, "Export [${export.videoLocal.name}] finished"); - StateDownloads.instance.removeExport(export); - notifyExport(export); - - withContext(Dispatchers.Main) { - StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), "File exported", "Exported [${file.uri}]", AnnouncementType.SESSION, time = null, category = "download", actionButton = "Open") { - file.share(this@ExportingService); - }; - } - } - - private fun notifyExport(export: VideoExport) { - val channel = _notificationChannel ?: return; - - val bringUpIntent = Intent(this, MainActivity::class.java); - bringUpIntent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); - bringUpIntent.action = "TAB"; - bringUpIntent.putExtra("TAB", "Exports"); - - var builder = NotificationCompat.Builder(this, EXPORT_NOTIF_TAG) - .setSmallIcon(R.drawable.ic_export) - .setOngoing(true) - .setSilent(true) - .setContentIntent(PendingIntent.getActivity(this, 5, bringUpIntent, PendingIntent.FLAG_IMMUTABLE)) - .setContentTitle("${export.state}: ${export.videoLocal.name}") - .setContentText(export.getExportInfo()) - .setProgress(100, (export.progress * 100).toInt(), export.progress == 0.0) - .setChannelId(channel.id) - - val notif = builder.build(); - notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR; - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground(EXPORT_NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); - } else { - startForeground(EXPORT_NOTIF_ID, notif); - } - } - - fun closeExportSession() { - Logger.i(TAG, "closeExportSession"); - stopForeground(STOP_FOREGROUND_REMOVE); - _notificationManager?.cancel(EXPORT_NOTIF_ID); - stopService(); - _started = false; - super.stopSelf(); - } - override fun onDestroy() { - Logger.i(TAG, "onDestroy"); - _instance = null; - _scope.cancel("onDestroy"); - super.onDestroy(); - } - - companion object { - private var _instance: ExportingService? = null; - private var _callOnStarted: ((ExportingService)->Unit)? = null; - - @Synchronized - fun getOrCreateService(context: Context, handle: ((ExportingService)->Unit)? = null) { - if(!FragmentedStorage.isInitialized) - return; - if(_instance == null) { - _callOnStarted = handle; - val intent = Intent(context, ExportingService::class.java); - context.startForegroundService(intent); - } - else _instance?.let { - if(handle != null) - handle(it); - } - } - @Synchronized - fun getService() : ExportingService? { - return _instance; - } - - @Synchronized - fun stopService(service: ExportingService? = null) { - (service ?: _instance)?.let { - if(_instance == it) - _instance = null; - it.closeExportSession(); - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 7fd26d63..ae0f24a3 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -445,9 +445,6 @@ class StateApp { DownloadService.getOrCreateService(context); } - Logger.i(TAG, "MainApp Started: Check [Exports]"); - StateDownloads.instance.checkForExportTodos(); - Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]"); val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled(); val shouldDownload = Settings.instance.autoUpdate.shouldDownload(); diff --git a/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt index edeb1859..c07f74b9 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt @@ -1,13 +1,13 @@ package com.futo.platformplayer.states import android.content.ContentResolver +import android.content.Context import android.os.StatFs 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.PlatformID -import com.futo.platformplayer.api.media.exceptions.AlreadyQueuedException import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource @@ -27,10 +27,14 @@ import com.futo.platformplayer.models.DiskUsage import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.PlaylistDownloaded import com.futo.platformplayer.services.DownloadService -import com.futo.platformplayer.services.ExportingService +import com.futo.platformplayer.share import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.v2.ManagedStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File +import java.util.UUID /*** * Used to maintain downloads @@ -50,12 +54,8 @@ class StateDownloads { private val _downloadPlaylists = FragmentedStorage.storeJson("playlistDownloads") .load(); - private val _exporting = FragmentedStorage.storeJson("exporting") - .load(); - private lateinit var _downloadedSet: HashSet; - val onExportsChanged = Event0(); val onDownloadsChanged = Event0(); val onDownloadedChanged = Event0(); @@ -457,17 +457,6 @@ class StateDownloads { } } - try { - val currentDownloads = _downloaded.getItems().map { it.url }.toHashSet(); - val exporting = _exporting.findItems { !currentDownloads.contains(it.videoLocal.url) }; - for (export in exporting) - _exporting.delete(export); - } - catch(ex: Throwable) { - Logger.e(TAG, "Failed to delete dangling export:", ex); - UIDialogs.toast("Failed to delete dangling export:\n" + ex); - } - return Pair(totalDeletedCount, totalDeleted); } @@ -475,66 +464,41 @@ class StateDownloads { return _downloadsDirectory; } + fun export(context: Context, videoLocal: VideoLocal, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?) { + var lastNotifyTime = -1L; + UIDialogs.showDialogProgress(context) { + it.setText("Exporting content.."); + it.setProgress(0f); + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + val export = VideoExport(videoLocal, videoSource, audioSource, subtitleSource); + try { + Logger.i(TAG, "Exporting [${export.videoLocal.name}] started"); - //Export - fun getExporting(): List { - return _exporting.getItems(); - } - fun checkForExportTodos() { - if(_exporting.hasItems()) { - StateApp.withContext { - ExportingService.getOrCreateService(it); + val file = export.export(context) { progress -> + val now = System.currentTimeMillis(); + if (lastNotifyTime == -1L || now - lastNotifyTime > 100) { + it.setProgress(progress); + lastNotifyTime = now; + } + } + + withContext(Dispatchers.Main) { + it.setProgress(100.0f) + it.dismiss() + + StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), "File exported", "Exported [${file.uri}]", AnnouncementType.SESSION, time = null, category = "download", actionButton = "Open") { + file.share(context); + }; + } + } catch(ex: Throwable) { + Logger.e(TAG, "Failed export [${export.videoLocal.name}]: ${ex.message}", ex); + + } } } } - fun validateExport(export: VideoExport) { - if(_exporting.hasItem { it.videoLocal.url == export.videoLocal.url }) - throw AlreadyQueuedException("Video [${export.videoLocal.name}] is already queued for export"); - } - fun export(videoLocal: VideoLocal, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, notify: Boolean = true) { - val shortName = if(videoLocal.name.length > 23) - videoLocal.name.substring(0, 20) + "..."; - else - videoLocal.name; - - val videoExport = VideoExport(videoLocal, videoSource, audioSource, subtitleSource); - - try { - validateExport(videoExport); - _exporting.save(videoExport); - - if(notify) { - UIDialogs.toast("Exporting [${shortName}]"); - StateApp.withContext { ExportingService.getOrCreateService(it) }; - onExportsChanged.emit(); - } - } - catch (ex: AlreadyQueuedException) { - Logger.e(TAG, "File is already queued for export.", ex); - StateApp.withContext { ExportingService.getOrCreateService(it) }; - } - catch(ex: Throwable) { - StateApp.withContext { - UIDialogs.showDialog( - it, - R.drawable.ic_error, - "Failed to start export due to:\n${ex.message}", null, null, - 0, - UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY) - ); - } - } - } - - - fun removeExport(export: VideoExport) { - _exporting.delete(export); - export.isCancelled = true; - onExportsChanged.emit(); - } - companion object { const val TAG = "StateDownloads"; diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt index dbfa8886..3e14ff2f 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -647,6 +647,15 @@ class StatePlatform { return client.getPlaybackTracker(url); } + fun getContentRecommendations(url: String): IPager? { + val baseClient = getContentClientOrNull(url) ?: return null; + if (baseClient !is JSClient) { + return baseClient.getContentRecommendations(url); + } + val client = _mainClientPool.getClientPooled(baseClient); + return client.getContentRecommendations(url); + } + fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { it.isChannelUrl(url) }; fun getChannelClient(url : String, exclude: List? = null) : IPlatformClient = getChannelClientOrNull(url, exclude) ?: throw NoPlatformClientException("No client enabled that supports this channel url (${url})"); diff --git a/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt b/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt index 7dc464a0..62da748b 100644 --- a/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt @@ -1,7 +1,6 @@ package com.futo.platformplayer.views import android.content.Context -import android.content.Intent import android.net.Uri import android.util.AttributeSet import android.view.View @@ -49,6 +48,7 @@ class MonetizationView : LinearLayout { private val _taskLoadMerchandise = TaskHandler>(StateApp.instance.scopeGetter, { url -> val client = ManagedHttpClient(); + Logger.i(TAG, "Loading https://storecache.grayjay.app/StoreData?url=$url") val result = client.get("https://storecache.grayjay.app/StoreData?url=$url") if (!result.isOk) { throw Exception("Failed to retrieve store data."); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt index de0f6c97..8bb2b946 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt @@ -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 = arrayOfNulls(4); - val onContentUrlClicked = Event2(); - val onUrlClicked = Event1(); - val onContentClicked = Event2(); - val onChannelClicked = Event1(); - val onAddToClicked = Event1(); - val onAddToQueueClicked = Event1(); - val onAddToWatchLaterClicked = Event1(); - val onLongPress = Event1(); +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() + val onUrlClicked = Event1() + val onContentClicked = Event2() + val onChannelClicked = Event1() + val onAddToClicked = Event1() + val onAddToQueueClicked = Event1() + val onAddToWatchLaterClicked = Event1() + val onLongPress = Event1() + + 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 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 } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/InsertedViewAdapterWithLoader.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/InsertedViewAdapterWithLoader.kt index de012903..19989056 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/InsertedViewAdapterWithLoader.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/InsertedViewAdapterWithLoader.kt @@ -15,6 +15,7 @@ import com.futo.platformplayer.R open class InsertedViewAdapterWithLoader : InsertedViewAdapter where TViewHolder : ViewHolder { private var _loaderView: ImageView? = null; private var _loading = false; + val isLoading get() = _loading; constructor( context: Context, diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt index a35d8147..b606bf26 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt @@ -33,6 +33,7 @@ open class PlaylistView : LinearLayout { protected val _platformIndicator: PlatformIndicator; protected val _textPlaylistName: TextView protected val _textVideoCount: TextView + protected val _textVideoCountLabel: TextView; protected val _textPlaylistItems: TextView protected val _textChannelName: TextView protected var _neopassAnimator: ObjectAnimator? = null; @@ -62,6 +63,7 @@ open class PlaylistView : LinearLayout { _platformIndicator = findViewById(R.id.thumbnail_platform); _textPlaylistName = findViewById(R.id.text_playlist_name); _textVideoCount = findViewById(R.id.text_video_count); + _textVideoCountLabel = findViewById(R.id.text_video_count_label); _textChannelName = findViewById(R.id.text_channel_name); _textPlaylistItems = findViewById(R.id.text_playlist_items); _imageNeopassChannel = findViewById(R.id.image_neopass_channel); @@ -137,7 +139,15 @@ open class PlaylistView : LinearLayout { .crossfade() .into(_imageThumbnail); - _textVideoCount.text = content.videoCount.toString(); + if(content.videoCount >= 0) { + _textVideoCount.text = content.videoCount.toString(); + _textVideoCount.visibility = View.VISIBLE; + _textVideoCountLabel.visibility = VISIBLE; + } + else { + _textVideoCount.visibility = View.GONE; + _textVideoCountLabel.visibility = GONE; + } } else { currentPlaylist = null; diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt index 8f94f6d2..3cf3194b 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt @@ -43,7 +43,7 @@ class VideoListEditorViewHolder : ViewHolder { val onRemove = Event1(); @SuppressLint("ClickableViewAccessibility") - constructor(view: View, touchHelper: ItemTouchHelper) : super(view) { + constructor(view: View, touchHelper: ItemTouchHelper? = null) : super(view) { _root = view.findViewById(R.id.root); _imageThumbnail = view.findViewById(R.id.image_video_thumbnail); _imageThumbnail?.clipToOutline = true; @@ -59,7 +59,7 @@ class VideoListEditorViewHolder : ViewHolder { _layoutDownloaded = view.findViewById(R.id.layout_downloaded); _imageDragDrop.setOnTouchListener { _, event -> - if (event.action == MotionEvent.ACTION_DOWN) { + if (touchHelper != null && event.action == MotionEvent.ACTION_DOWN) { touchHelper.startDrag(this); } false diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ImportPlaylistsViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ImportPlaylistsViewHolder.kt index 9cbf400c..66ab0193 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ImportPlaylistsViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ImportPlaylistsViewHolder.kt @@ -1,12 +1,14 @@ package com.futo.platformplayer.views.adapters.viewholders import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import com.bumptech.glide.Glide import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.views.adapters.AnyAdapter @@ -45,10 +47,15 @@ class ImportPlaylistsViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter. override fun bind(value: SelectablePlaylist) { _textName.text = value.playlist.name; - _textMetadata.text = "${value.playlist.videos.size} " + _view.context.getString(R.string.videos); + if(value.playlist.videoCount >= 0) { + _textMetadata.text = "${value.playlist.videoCount} " + _view.context.getString(R.string.videos); + _textMetadata.visibility = View.VISIBLE; + } + else + _textMetadata.visibility = View.GONE; _checkbox.value = value.selected; - val thumbnail = value.playlist.videos.firstOrNull()?.thumbnails?.getHQThumbnail(); + val thumbnail = value.playlist.thumbnail; if (thumbnail != null) Glide.with(_imageThumbnail) .load(thumbnail) @@ -62,6 +69,6 @@ class ImportPlaylistsViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter. } class SelectablePlaylist( - val playlist: Playlist, + val playlist: IPlatformPlaylistDetails, var selected: Boolean = false ) { } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/VideoDownloadViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/VideoDownloadViewHolder.kt index ae4e6ec9..5be5a4b0 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/VideoDownloadViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/VideoDownloadViewHolder.kt @@ -16,6 +16,8 @@ import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.views.adapters.AnyAdapter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class VideoDownloadViewHolder(_viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( @@ -57,10 +59,14 @@ class VideoDownloadViewHolder(_viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder< return@changeExternalDownloadDirectory; } - StateDownloads.instance.export(v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull()); + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + StateDownloads.instance.export(_viewGroup.context, v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull()); + } }; } else { - StateDownloads.instance.export(v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull()); + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + StateDownloads.instance.export(_viewGroup.context, v, v.videoSource.firstOrNull(), v.audioSource.firstOrNull(), v.subtitlesSources.firstOrNull()); + } } } } diff --git a/app/src/main/res/layout/dialog_progress.xml b/app/src/main/res/layout/dialog_progress.xml index 75b37e42..7bcc6228 100644 --- a/app/src/main/res/layout/dialog_progress.xml +++ b/app/src/main/res/layout/dialog_progress.xml @@ -45,6 +45,7 @@ android:textColor="@color/white" android:textSize="14dp" android:fontFamily="@font/inter_regular" + android:textAlignment="center" android:layout_marginTop="30dp" android:layout_marginStart="30dp" android:layout_marginEnd="30dp" /> diff --git a/app/src/main/res/layout/fragment_remote_playlist.xml b/app/src/main/res/layout/fragment_remote_playlist.xml new file mode 100644 index 00000000..bee1f714 --- /dev/null +++ b/app/src/main/res/layout/fragment_remote_playlist.xml @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +W + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_playlist_feed.xml b/app/src/main/res/layout/list_playlist_feed.xml index a9eb1ddc..f2f30845 100644 --- a/app/src/main/res/layout/list_playlist_feed.xml +++ b/app/src/main/res/layout/list_playlist_feed.xml @@ -68,6 +68,7 @@ android:textColor="@color/gray_7f"/> + app:layout_constraintRight_toLeftOf="@id/text_video_count_label" + app:layout_constraintBottom_toBottomOf="@id/text_video_count_label" /> + app:layout_constraintBottom_toTopOf="@id/text_video_count_label"/> Get answers to common questions Give feedback on the application Info + Networking Gesture controls Volume slider Enable slide gesture to change volume @@ -461,6 +462,8 @@ Deletes all ongoing downloads Deletes all unresolved source files Developer Mode + Allow All Certificates + This risks exposing all your Grayjay network traffic. Development Server Experimental Cache diff --git a/app/src/stable/AndroidManifest.xml b/app/src/stable/AndroidManifest.xml index 0f4a00b8..a5fdd260 100644 --- a/app/src/stable/AndroidManifest.xml +++ b/app/src/stable/AndroidManifest.xml @@ -30,6 +30,9 @@ + + + @@ -51,6 +54,9 @@ + + + diff --git a/app/src/stable/assets/sources/spotify b/app/src/stable/assets/sources/spotify new file mode 160000 index 00000000..4e826dcb --- /dev/null +++ b/app/src/stable/assets/sources/spotify @@ -0,0 +1 @@ +Subproject commit 4e826dcb6a237313e32ec81b0e973a4f69c429c3 diff --git a/app/src/stable/res/raw/plugin_config.json b/app/src/stable/res/raw/plugin_config.json index f679a30a..a1da4004 100644 --- a/app/src/stable/res/raw/plugin_config.json +++ b/app/src/stable/res/raw/plugin_config.json @@ -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" diff --git a/app/src/unstable/AndroidManifest.xml b/app/src/unstable/AndroidManifest.xml index 8a600a38..a5fdd260 100644 --- a/app/src/unstable/AndroidManifest.xml +++ b/app/src/unstable/AndroidManifest.xml @@ -4,7 +4,7 @@ - + @@ -30,6 +30,9 @@ + + + @@ -51,6 +54,9 @@ + + + diff --git a/app/src/unstable/assets/sources/spotify b/app/src/unstable/assets/sources/spotify new file mode 160000 index 00000000..4e826dcb --- /dev/null +++ b/app/src/unstable/assets/sources/spotify @@ -0,0 +1 @@ +Subproject commit 4e826dcb6a237313e32ec81b0e973a4f69c429c3 diff --git a/app/src/unstable/res/raw/plugin_config.json b/app/src/unstable/res/raw/plugin_config.json index db526f8f..551c5470 100644 --- a/app/src/unstable/res/raw/plugin_config.json +++ b/app/src/unstable/res/raw/plugin_config.json @@ -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" diff --git a/docs/packages/packageHttp.md b/docs/packages/packageHttp.md index 98491d14..d6a3bc3c 100644 --- a/docs/packages/packageHttp.md +++ b/docs/packages/packageHttp.md @@ -2,12 +2,12 @@ Package http is the main way for a plugin to make web requests, and is likely a package you will always need. It offers several ways to make web requests as well as websocket connections. -Before you can use http you need to register it in your plugin config. See [Packages](_blank). +Before you can use http you need to register it in your plugin config. See [Packages](/app/src/main/java/com/futo/platformplayer/engine/packages). ## Basic Info Underneath the http package by default exist two web clients. An authenticated client and a unauthenticated client. The authenticated client has will apply headers and cookies if the user is logged in with your plugin. -See [Plugin Authentication](_blank). +See [Plugin Authentication](/docs/Authentication.md). These two clients are always available even when the user is not logged in, meaning it behaves similar to the unauthenticated client and can safely use it either way. >:warning: **Requests are synchronous** diff --git a/sign-all-sources.sh b/sign-all-sources.sh index ff4c0de6..3a6d783f 100755 --- a/sign-all-sources.sh +++ b/sign-all-sources.sh @@ -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 \ No newline at end of file