From 7c256782118997ce4d4c9476b30a40da67005160 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Mon, 10 Mar 2025 22:12:17 +0100 Subject: [PATCH 01/19] Subgroup sub image url, ImageVariable default error icon on fail to load --- .../mainactivity/main/SubscriptionGroupFragment.kt | 2 ++ .../com/futo/platformplayer/models/ImageVariable.kt | 12 ++++++++++-- app/src/stable/assets/sources/spotify | 2 +- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/assets/sources/spotify | 2 +- app/src/unstable/assets/sources/youtube | 2 +- dep/polycentricandroid | 2 +- 7 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt index 9fd4d7d6..a2875647 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt @@ -256,6 +256,8 @@ class SubscriptionGroupFragment : MainFragment() { val sub = StateSubscriptions.instance.getSubscription(sub) ?: StateSubscriptions.instance.getSubscriptionOther(sub); if(sub != null && sub.channel.thumbnail != null) { g.image = ImageVariable.fromUrl(sub.channel.thumbnail!!); + if(g.image != null) + g.image!!.subscriptionUrl = sub.channel.url; g.image?.setImageView(_imageGroup); g.image?.setImageView(_imageGroupBackground); break; diff --git a/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt b/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt index 1de1f917..00594df7 100644 --- a/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt +++ b/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt @@ -7,6 +7,7 @@ import android.widget.ImageView import com.bumptech.glide.Glide import com.futo.platformplayer.PresetImages import com.futo.platformplayer.R +import com.futo.platformplayer.logging.Logger import kotlinx.serialization.Contextual import kotlinx.serialization.Transient import java.io.File @@ -18,7 +19,8 @@ data class ImageVariable( @Transient @Contextual private val bitmap: Bitmap? = null, - val presetName: String? = null) { + val presetName: String? = null, + var subscriptionUrl: String? = null) { @SuppressLint("DiscouragedApi") fun setImageView(imageView: ImageView, fallbackResId: Int = -1) { @@ -63,7 +65,13 @@ data class ImageVariable( return ImageVariable(null, null, null, str); } fun fromFile(file: File): ImageVariable { - return ImageVariable.fromBitmap(BitmapFactory.decodeFile(file.absolutePath)); + try { + return ImageVariable.fromBitmap(BitmapFactory.decodeFile(file.absolutePath)); + } + catch(ex: Throwable) { + Logger.e("ImageVariable", "Unsupported image format? " + ex.message, ex); + return fromResource(R.drawable.ic_error_pred); + } } } } \ No newline at end of file diff --git a/app/src/stable/assets/sources/spotify b/app/src/stable/assets/sources/spotify index 0d05e35c..9c36c457 160000 --- a/app/src/stable/assets/sources/spotify +++ b/app/src/stable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 0d05e35cfc81acfa78594c91c381b79694aaf86d +Subproject commit 9c36c457463fc2a452f76eb74465e9e234cdaf69 diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index 857c147b..6e0fe924 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 857c147b3a3d3e9d0a79c47f1bd5813e08ed2daf +Subproject commit 6e0fe9245143336a31954d678cdd22cbc3e0e115 diff --git a/app/src/unstable/assets/sources/spotify b/app/src/unstable/assets/sources/spotify index 0d05e35c..9c36c457 160000 --- a/app/src/unstable/assets/sources/spotify +++ b/app/src/unstable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 0d05e35cfc81acfa78594c91c381b79694aaf86d +Subproject commit 9c36c457463fc2a452f76eb74465e9e234cdaf69 diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index 857c147b..6e0fe924 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 857c147b3a3d3e9d0a79c47f1bd5813e08ed2daf +Subproject commit 6e0fe9245143336a31954d678cdd22cbc3e0e115 diff --git a/dep/polycentricandroid b/dep/polycentricandroid index 44edd69e..f87f00ab 160000 --- a/dep/polycentricandroid +++ b/dep/polycentricandroid @@ -1 +1 @@ -Subproject commit 44edd69ece9cac4a6dd95a84ca91299e44f3650a +Subproject commit f87f00ab9e1262e300246b8963591bdf3a8fada7 From 9165a9f7cbc3af0466ee6c67a00be9715cb32fb0 Mon Sep 17 00:00:00 2001 From: Kai DeLorenzo Date: Mon, 17 Mar 2025 23:24:15 +0000 Subject: [PATCH 02/19] Add : support for login button selector --- .../java/com/futo/platformplayer/activities/LoginActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt index 36041919..6ea7bd67 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt @@ -113,7 +113,7 @@ class LoginActivity : AppCompatActivity() { companion object { private val TAG = "LoginActivity"; - private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#_ ]*"); + private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#:_ ]*"); private var _callback: ((SourceAuth?) -> Unit)? = null; From 54d58df4b6080396162f438c12a82961b9c3087d Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Fri, 21 Mar 2025 02:23:55 +0100 Subject: [PATCH 03/19] Sync watch later on initial connection, Original audio boolean support, priority audio support, setting to prefer original audio --- .../java/com/futo/platformplayer/Settings.kt | 4 +++- .../futo/platformplayer/UISlideOverlays.kt | 2 +- .../models/streams/sources/AudioUrlSource.kt | 7 +++++-- .../streams/sources/HLSVariantUrlSource.kt | 1 + .../models/streams/sources/IAudioSource.kt | 1 + .../streams/sources/LocalAudioSource.kt | 1 + .../js/models/sources/JSAudioUrlSource.kt | 3 +++ .../sources/JSDashManifestRawAudioSource.kt | 2 ++ .../sources/JSHLSManifestAudioSource.kt | 2 ++ .../platformplayer/helpers/VideoHelper.kt | 10 ++++++++-- .../com/futo/platformplayer/parsers/HLS.kt | 4 ++-- .../platformplayer/states/StatePlaylists.kt | 19 +++++++++++-------- .../sync/internal/SyncSession.kt | 2 ++ app/src/main/res/values/strings.xml | 2 ++ 14 files changed, 44 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index c95947ea..c421c9a3 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -356,7 +356,7 @@ class Settings : FragmentedStorageFileJson() { var playback = PlaybackSettings(); @Serializable class PlaybackSettings { - @FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, -1) + @FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, -2) @DropdownFieldOptionsId(R.array.audio_languages) var primaryLanguage: Int = 0; @@ -380,6 +380,8 @@ class Settings : FragmentedStorageFileJson() { else -> null } } + @FormField(R.string.prefer_original_audio, FieldForm.TOGGLE, R.string.prefer_original_audio_description, -1) + var preferOriginalAudio: Boolean = true; //= context.resources.getStringArray(R.array.audio_languages)[primaryLanguage]; diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index 67497b1e..a1aa71b7 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -402,7 +402,7 @@ class UISlideOverlays { UIDialogs.toast(container.context, "Variant video HLS playlist download started") slideUpMenuOverlay.hide() } else if (source is IHLSManifestAudioSource) { - StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, sourceUrl), null) + StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, sourceUrl), null) UIDialogs.toast(container.context, "Variant audio HLS playlist download started") slideUpMenuOverlay.hide() } else { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/AudioUrlSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/AudioUrlSource.kt index 67548b89..a4d2cb55 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/AudioUrlSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/AudioUrlSource.kt @@ -13,7 +13,8 @@ class AudioUrlSource( override val codec: String = "", override val language: String = Language.UNKNOWN, override val duration: Long? = null, - override var priority: Boolean = false + override var priority: Boolean = false, + override var original: Boolean = false ) : IAudioUrlSource, IStreamMetaDataSource{ override var streamMetaData: StreamMetaData? = null; @@ -36,7 +37,9 @@ class AudioUrlSource( source.container, source.codec, source.language, - source.duration + source.duration, + source.priority, + source.original ); ret.streamMetaData = streamData; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt index 36df5fb2..854cf9b8 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt @@ -27,6 +27,7 @@ class HLSVariantAudioUrlSource( override val language: String, override val duration: Long?, override val priority: Boolean, + override val original: Boolean, val url: String ) : IAudioUrlSource { override fun getAudioUrl(): String { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IAudioSource.kt index eca17e47..f2c95b08 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IAudioSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IAudioSource.kt @@ -8,4 +8,5 @@ interface IAudioSource { val language : String; val duration : Long?; val priority: Boolean; + val original: Boolean; } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalAudioSource.kt index 254b9731..1f616307 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalAudioSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalAudioSource.kt @@ -15,6 +15,7 @@ class LocalAudioSource : IAudioSource, IStreamMetaDataSource { override val duration: Long? = null; override var priority: Boolean = false; + override val original: Boolean = false; val filePath : String; val fileSize: Long; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioUrlSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioUrlSource.kt index 09de1f35..0edc4f73 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioUrlSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioUrlSource.kt @@ -21,6 +21,8 @@ open class JSAudioUrlSource : IAudioUrlSource, JSSource { override var priority: Boolean = false; + override var original: Boolean = false; + constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_AUDIOURL, plugin, obj) { val contextName = "AudioUrlSource"; val config = plugin.config; @@ -35,6 +37,7 @@ open class JSAudioUrlSource : IAudioUrlSource, JSSource { name = _obj.getOrDefault(config, "name", contextName, "${container} ${bitrate}") ?: "${container} ${bitrate}"; priority = if(_obj.has("priority")) obj.getOrThrow(config, "priority", contextName) else false; + original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false; } override fun getAudioUrl() : String { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt index 93ed6a01..ae35207b 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt @@ -23,6 +23,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS override val bitrate: Int; override val duration: Long; override val priority: Boolean; + override var original: Boolean = false; override val language: String; @@ -45,6 +46,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0; priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false; language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN; + original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false; hasGenerate = _obj.has("generate"); } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt index 41948802..9e328df3 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt @@ -21,6 +21,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource { override val language: String; override var priority: Boolean = false; + override var original: Boolean = false; constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) { val contextName = "HLSAudioSource"; @@ -32,6 +33,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource { language = _obj.getOrThrow(config, "language", contextName); priority = obj.getOrNull(config, "priority", contextName) ?: false; + original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false; } 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 87e8f051..522647de 100644 --- a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt +++ b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt @@ -9,6 +9,7 @@ import androidx.media3.datasource.ResolvingDataSource import androidx.media3.exoplayer.dash.DashMediaSource import androidx.media3.exoplayer.dash.manifest.DashManifestParser import androidx.media3.exoplayer.source.MediaSource +import com.futo.platformplayer.Settings 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 @@ -85,12 +86,17 @@ class VideoHelper { return selectBestAudioSource((desc as VideoUnMuxedSourceDescriptor).audioSources.toList(), prefContainers, prefLanguage, targetBitrate); } - fun selectBestAudioSource(altSources : Iterable, prefContainers : Array, preferredLanguage: String? = null, targetBitrate: Long? = null) : IAudioSource? { + fun selectBestAudioSource(sources : Iterable, prefContainers : Array, preferredLanguage: String? = null, targetBitrate: Long? = null) : IAudioSource? { + val hasPriority = sources.any { it.priority }; + var altSources = if(hasPriority) sources.filter { it.priority } else sources; + val hasOriginal = altSources.any { it.original }; + if(hasOriginal && Settings.instance.playback.preferOriginalAudio) + altSources = altSources.filter { it.original }; val languageToFilter = if(preferredLanguage != null && altSources.any { it.language == preferredLanguage }) { preferredLanguage } else { if(altSources.any { it.language == Language.ENGLISH }) - Language.ENGLISH + Language.ENGLISH; else Language.UNKNOWN; } diff --git a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt index 9d1a3faa..7d57a151 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -119,7 +119,7 @@ class HLS { return if (source is IHLSManifestSource) { listOf() } else if (source is IHLSManifestAudioSource) { - listOf(HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, url)) + listOf(HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, url)) } else { throw NotImplementedError() } @@ -340,7 +340,7 @@ class HLS { val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ") return@mapNotNull when (it.type) { - "AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri) + "AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, false, it.uri) else -> null } } diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt index 4a41760a..e2054c90 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt @@ -184,7 +184,7 @@ class StatePlaylists { wasNew = true; _watchlistStore.saveAsync(video); if(orderPosition == -1) - _watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values) .toTypedArray()); + _watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values).toTypedArray()); else { val existing = _watchlistOrderStore.getAllValues().toMutableList(); existing.add(orderPosition, video.url); @@ -230,17 +230,20 @@ class StatePlaylists { } } + public fun getWatchLaterSyncPacket(orderOnly: Boolean = false): SyncWatchLaterPackage{ + return SyncWatchLaterPackage( + if (orderOnly) listOf() else getWatchLater(), + if (orderOnly) mapOf() else _watchLaterAdds.all(), + if (orderOnly) mapOf() else _watchLaterRemovals.all(), + getWatchLaterLastReorderTime().toEpochSecond(), + _watchlistOrderStore.values.toList() + ) + } private fun broadcastWatchLater(orderOnly: Boolean = false) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { try { StateSync.instance.broadcastJsonData( - GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage( - if (orderOnly) listOf() else getWatchLater(), - if (orderOnly) mapOf() else _watchLaterAdds.all(), - if (orderOnly) mapOf() else _watchLaterRemovals.all(), - getWatchLaterLastReorderTime().toEpochSecond(), - _watchlistOrderStore.values.toList() - ) + GJSyncOpcodes.syncWatchLater, getWatchLaterSyncPacket(orderOnly) ); } catch (e: Throwable) { Logger.w(TAG, "Failed to broadcast watch later", e) diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt index 8b5621e0..e4273d63 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt @@ -232,6 +232,8 @@ class SyncSession : IAuthorizable { sendData(GJSyncOpcodes.syncSubscriptionGroups, StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString()); sendData(GJSyncOpcodes.syncPlaylists, StatePlaylists.instance.getSyncPlaylistsPackageString()) + sendData(GJSyncOpcodes.syncWatchLater, Json.encodeToString(StatePlaylists.instance.getWatchLaterSyncPacket(false))); + val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory); if(recentHistory.size > 0) sendJsonData(GJSyncOpcodes.syncHistory, recentHistory); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d4df1905..8f5605c0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -447,6 +447,8 @@ Preferred Preview Quality Default quality while previewing a video in a feed Primary Language + Prefer Original Audio + Use original audio instead of preferred language when it is known Default Comment Section Hide Recommendations Fully hide the recommendations tab. From 7bd687331b62222d1bbfc772ad66f26661863054 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Mon, 24 Mar 2025 20:33:17 +0100 Subject: [PATCH 04/19] AddSource by url support, submods --- .../activities/AddSourceOptionsActivity.kt | 23 ++- .../layout/activity_add_source_options.xml | 142 ++++++++++-------- app/src/stable/assets/sources/kick | 2 +- app/src/stable/assets/sources/soundcloud | 2 +- app/src/stable/assets/sources/spotify | 2 +- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/assets/sources/kick | 2 +- app/src/unstable/assets/sources/soundcloud | 2 +- app/src/unstable/assets/sources/spotify | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 10 files changed, 106 insertions(+), 75 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/AddSourceOptionsActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/AddSourceOptionsActivity.kt index 46872559..f237638e 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/AddSourceOptionsActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/AddSourceOptionsActivity.kt @@ -10,11 +10,13 @@ import androidx.appcompat.app.AppCompatActivity import com.futo.platformplayer.* import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.views.buttons.BigButton +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput import com.google.zxing.integration.android.IntentIntegrator class AddSourceOptionsActivity : AppCompatActivity() { lateinit var _buttonBack: ImageButton; + lateinit var _overlayContainer: FrameLayout; lateinit var _buttonQR: BigButton; lateinit var _buttonBrowse: BigButton; lateinit var _buttonURL: BigButton; @@ -54,6 +56,7 @@ class AddSourceOptionsActivity : AppCompatActivity() { setContentView(R.layout.activity_add_source_options); setNavigationBarColorAndIcons(); + _overlayContainer = findViewById(R.id.overlay_container); _buttonBack = findViewById(R.id.button_back); _buttonQR = findViewById(R.id.option_qr); @@ -81,7 +84,25 @@ class AddSourceOptionsActivity : AppCompatActivity() { } _buttonURL.onClick.subscribe { - UIDialogs.toast(this, getString(R.string.not_implemented_yet)); + val nameInput = SlideUpMenuTextInput(this, "ex. https://yourplugin.com/config.json"); + UISlideOverlays.showOverlay(_overlayContainer, "Enter your url", "Install", { + + val content = nameInput.text; + + val url = if (content.startsWith("https://")) { + content + } else if (content.startsWith("grayjay://plugin/")) { + content.substring("grayjay://plugin/".length) + } else { + UIDialogs.toast(this, getString(R.string.not_a_plugin_url)) + return@showOverlay; + } + + val intent = Intent(this, AddSourceActivity::class.java).apply { + data = Uri.parse(url); + }; + startActivity(intent); + }, nameInput) } } } \ No newline at end of file diff --git a/app/src/main/res/layout/activity_add_source_options.xml b/app/src/main/res/layout/activity_add_source_options.xml index 541d489d..e9c1b001 100644 --- a/app/src/main/res/layout/activity_add_source_options.xml +++ b/app/src/main/res/layout/activity_add_source_options.xml @@ -1,5 +1,5 @@ - - + android:layout_height="match_parent" + android:orientation="vertical"> - - - + android:orientation="horizontal" + android:gravity="center_vertical" + android:paddingTop="20dp" + android:paddingBottom="15dp"> - + + - + android:layout_weight="1"> - + + + + + + + + + + + - - - - - - \ No newline at end of file + android:layout_height="match_parent" + android:visibility="gone" /> + \ No newline at end of file diff --git a/app/src/stable/assets/sources/kick b/app/src/stable/assets/sources/kick index 2046944c..3a0efd1f 160000 --- a/app/src/stable/assets/sources/kick +++ b/app/src/stable/assets/sources/kick @@ -1 +1 @@ -Subproject commit 2046944c18f48c15dfbea82f3f89d7ba6dce5e14 +Subproject commit 3a0efd1fc4db63c15334a190ab69a8fb4498ae23 diff --git a/app/src/stable/assets/sources/soundcloud b/app/src/stable/assets/sources/soundcloud index ae47f2ea..bff981c3 160000 --- a/app/src/stable/assets/sources/soundcloud +++ b/app/src/stable/assets/sources/soundcloud @@ -1 +1 @@ -Subproject commit ae47f2eaacaf2879405435358965c47eb3d48096 +Subproject commit bff981c3ce7abad363e79705214a4710fb347f7d diff --git a/app/src/stable/assets/sources/spotify b/app/src/stable/assets/sources/spotify index 9c36c457..331dd929 160000 --- a/app/src/stable/assets/sources/spotify +++ b/app/src/stable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 9c36c457463fc2a452f76eb74465e9e234cdaf69 +Subproject commit 331dd929293614875af80e3ab4cb162dc6183410 diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index 6e0fe924..ae7b62f4 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 6e0fe9245143336a31954d678cdd22cbc3e0e115 +Subproject commit ae7b62f4d85cf398d9566a6ed07a6afc193f6b48 diff --git a/app/src/unstable/assets/sources/kick b/app/src/unstable/assets/sources/kick index 2046944c..3a0efd1f 160000 --- a/app/src/unstable/assets/sources/kick +++ b/app/src/unstable/assets/sources/kick @@ -1 +1 @@ -Subproject commit 2046944c18f48c15dfbea82f3f89d7ba6dce5e14 +Subproject commit 3a0efd1fc4db63c15334a190ab69a8fb4498ae23 diff --git a/app/src/unstable/assets/sources/soundcloud b/app/src/unstable/assets/sources/soundcloud index ae47f2ea..bff981c3 160000 --- a/app/src/unstable/assets/sources/soundcloud +++ b/app/src/unstable/assets/sources/soundcloud @@ -1 +1 @@ -Subproject commit ae47f2eaacaf2879405435358965c47eb3d48096 +Subproject commit bff981c3ce7abad363e79705214a4710fb347f7d diff --git a/app/src/unstable/assets/sources/spotify b/app/src/unstable/assets/sources/spotify index 9c36c457..331dd929 160000 --- a/app/src/unstable/assets/sources/spotify +++ b/app/src/unstable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 9c36c457463fc2a452f76eb74465e9e234cdaf69 +Subproject commit 331dd929293614875af80e3ab4cb162dc6183410 diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index 6e0fe924..ae7b62f4 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 6e0fe9245143336a31954d678cdd22cbc3e0e115 +Subproject commit ae7b62f4d85cf398d9566a6ed07a6afc193f6b48 From 034b8b15aedcc411557d0976decd1b831a92a982 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 26 Mar 2025 23:28:32 +0100 Subject: [PATCH 05/19] WIP SubsExchange --- .../java/com/futo/platformplayer/Settings.kt | 3 + .../states/StateSubscriptions.kt | 19 ++- .../SmartSubscriptionAlgorithm.kt | 6 +- .../SubscriptionFetchAlgorithm.kt | 5 +- .../SubscriptionsTaskFetchAlgorithm.kt | 59 ++++++++- .../subsexchange/ChannelRequest.kt | 8 ++ .../subsexchange/ChannelResolve.kt | 13 ++ .../subsexchange/ChannelResult.kt | 17 +++ .../subsexchange/ExchangeContract.kt | 17 +++ .../subsexchange/ExchangeContractResolve.kt | 10 ++ .../subsexchange/SubsExchangeClient.kt | 118 ++++++++++++++++++ .../main/res/layout/fragment_downloads.xml | 2 +- app/src/main/res/values/strings.xml | 2 + 13 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt create mode 100644 app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt create mode 100644 app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt create mode 100644 app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt create mode 100644 app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt create mode 100644 app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index c421c9a3..989997c3 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -294,6 +294,9 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.show_subscription_group, FieldForm.TOGGLE, R.string.show_subscription_group_description, 5) var showSubscriptionGroups: Boolean = true; + @FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6) + var useSubscriptionExchange: Boolean = false; + @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6) var previewFeedItems: Boolean = true; diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt index 65892a1e..f0a56b41 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.states +import SubsExchangeClient import com.futo.platformplayer.Settings import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.models.channels.IPlatformChannel @@ -18,6 +19,7 @@ import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringDateMapStorage +import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.stores.StringStringMapStorage import com.futo.platformplayer.stores.SubscriptionStorage import com.futo.platformplayer.stores.v2.ReconstructStore @@ -67,10 +69,24 @@ class StateSubscriptions { val onSubscriptionsChanged = Event2, Boolean>(); + private val _subsExchangeServer = "https://exchange.grayjay.app/"; + private val _subscriptionKey = FragmentedStorage.get("sub_exchange_key"); + init { global.onUpdateProgress.subscribe { progress, total -> onFeedProgress.emit(null, progress, total); } + if(_subscriptionKey.value.isNullOrBlank()) + generateNewSubsExchangeKey(); + } + + fun generateNewSubsExchangeKey(){ + _subscriptionKey.setAndSave(SubsExchangeClient.createPrivateKey()); + } + fun getSubsExchangeClient(): SubsExchangeClient { + if(_subscriptionKey.value.isNullOrBlank()) + throw IllegalStateException("No valid subscription exchange key set"); + return SubsExchangeClient(_subsExchangeServer, _subscriptionKey.value); } fun getOldestUpdateTime(): OffsetDateTime { @@ -359,7 +375,8 @@ class StateSubscriptions { } fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null, subGroup: SubscriptionGroup? = null): Pair, List> { - val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool); + val exchangeClient = if(Settings.instance.subscriptions.useSubscriptionExchange) getSubsExchangeClient() else null; + val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool, exchangeClient); if(onNewCacheHit != null) algo.onNewCacheHit.subscribe(onNewCacheHit) diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SmartSubscriptionAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SmartSubscriptionAlgorithm.kt index dfed7fd2..de89d195 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SmartSubscriptionAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SmartSubscriptionAlgorithm.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.subscription +import SubsExchangeClient import com.futo.platformplayer.Settings import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.platforms.js.JSClient @@ -15,8 +16,9 @@ class SmartSubscriptionAlgorithm( scope: CoroutineScope, allowFailure: Boolean = false, withCacheFallback: Boolean = true, - threadPool: ForkJoinPool? = null -): SubscriptionsTaskFetchAlgorithm(scope, allowFailure, withCacheFallback, threadPool) { + threadPool: ForkJoinPool? = null, + subsExchangeClient: SubsExchangeClient? = null +): SubscriptionsTaskFetchAlgorithm(scope, allowFailure, withCacheFallback, threadPool, subsExchangeClient) { override fun getSubscriptionTasks(subs: Map>): List { val allTasks: List = subs.flatMap { entry -> val sub = entry.key; diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionFetchAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionFetchAlgorithm.kt index a34f0e33..269a75f5 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionFetchAlgorithm.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.subscription +import SubsExchangeClient import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.structures.IPager @@ -33,11 +34,11 @@ abstract class SubscriptionFetchAlgorithm( companion object { public val TAG = "SubscriptionAlgorithm"; - fun getAlgorithm(algo: SubscriptionFetchAlgorithms, scope: CoroutineScope, allowFailure: Boolean = false, withCacheFallback: Boolean = false, pool: ForkJoinPool? = null): SubscriptionFetchAlgorithm { + fun getAlgorithm(algo: SubscriptionFetchAlgorithms, scope: CoroutineScope, allowFailure: Boolean = false, withCacheFallback: Boolean = false, pool: ForkJoinPool? = null, withExchangeClient: SubsExchangeClient? = null): SubscriptionFetchAlgorithm { return when(algo) { SubscriptionFetchAlgorithms.CACHE -> CachedSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool, 50); SubscriptionFetchAlgorithms.SIMPLE -> SimpleSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool); - SubscriptionFetchAlgorithms.SMART -> SmartSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool); + SubscriptionFetchAlgorithms.SMART -> SmartSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool, withExchangeClient); } } } diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt index eff83030..dfec6fdf 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.subscription +import SubsExchangeClient import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.models.ResultCapabilities @@ -10,6 +11,7 @@ import com.futo.platformplayer.api.media.structures.DedupContentPager import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.MultiChronoContentPager +import com.futo.platformplayer.api.media.structures.PlatformContentPager import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCriticalException @@ -24,6 +26,8 @@ import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StateSubscriptions +import com.futo.platformplayer.subsexchange.ChannelRequest +import com.futo.platformplayer.subsexchange.ChannelResolve import kotlinx.coroutines.CoroutineScope import java.time.OffsetDateTime import java.util.concurrent.ExecutionException @@ -35,7 +39,8 @@ abstract class SubscriptionsTaskFetchAlgorithm( scope: CoroutineScope, allowFailure: Boolean = false, withCacheFallback: Boolean = true, - _threadPool: ForkJoinPool? = null + _threadPool: ForkJoinPool? = null, + private val subsExchangeClient: SubsExchangeClient? = null ) : SubscriptionFetchAlgorithm(scope, allowFailure, withCacheFallback, _threadPool) { @@ -45,7 +50,7 @@ abstract class SubscriptionsTaskFetchAlgorithm( } override fun getSubscriptions(subs: Map>): Result { - val tasks = getSubscriptionTasks(subs); + var tasks = getSubscriptionTasks(subs).toMutableList() val tasksGrouped = tasks.groupBy { it.client } @@ -70,6 +75,21 @@ abstract class SubscriptionsTaskFetchAlgorithm( val exs: ArrayList = arrayListOf(); + + + val liveTasks = tasks.filter { !it.fromPeek && !it.fromCache }; + val contract = subsExchangeClient?.requestContract(*liveTasks.map { ChannelRequest(it.url) }.toTypedArray()); + var providedTasks: MutableList? = null; + if(contract != null && contract.provided.size > 0){ + providedTasks = mutableListOf() + for(task in tasks.toList()){ + if(!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) { + providedTasks.add(task); + tasks.remove(task); + } + } + } + val failedPlugins = mutableListOf(); val cachedChannels = mutableListOf() val forkTasks = executeSubscriptionTasks(tasks, failedPlugins, cachedChannels); @@ -104,6 +124,39 @@ abstract class SubscriptionsTaskFetchAlgorithm( }; } } + + //Resolve Subscription Exchange + if(contract != null) { + try { + val resolve = subsExchangeClient?.resolveContract( + contract, + *taskResults.filter { it.pager != null }.map { + ChannelResolve( + it.task.url, + it.pager!!.getResults() + ) + }.toTypedArray() + ); + if (resolve != null) { + for(result in resolve){ + val task = providedTasks?.find { it.url == result.channelUrl }; + if(task != null) { + taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null)); + providedTasks?.remove(task); + } + } + } + if (providedTasks != null) { + for(task in providedTasks) { + taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange"))); + } + } + } + catch(ex: Throwable) { + //TODO: fetch remainder after all? + } + } + Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms") //Cache pagers grouped by channel @@ -173,6 +226,8 @@ abstract class SubscriptionsTaskFetchAlgorithm( Logger.e(StateSubscriptions.TAG, "Subscription peek [${task.sub.channel.name}] failed", ex); } } + + //Intercepts task.fromCache & task.fromPeek synchronized(cachedChannels) { if(task.fromCache || task.fromPeek) { finished++; diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt new file mode 100644 index 00000000..cde158d5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt @@ -0,0 +1,8 @@ +package com.futo.platformplayer.subsexchange + +import kotlinx.serialization.Serializable + +@Serializable +class ChannelRequest( + var url: String +); \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt new file mode 100644 index 00000000..eaedc191 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt @@ -0,0 +1,13 @@ +package com.futo.platformplayer.subsexchange + +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import kotlinx.serialization.Serializable +import java.time.OffsetDateTime + +@Serializable +class ChannelResolve( + var channelUrl: String, + var content: List, + var channel: IPlatformChannel? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt new file mode 100644 index 00000000..f55f2451 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt @@ -0,0 +1,17 @@ +package com.futo.platformplayer.subsexchange + +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer +import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import kotlinx.serialization.Serializable +import java.time.OffsetDateTime + +@Serializable +class ChannelResult( + @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + var dateTime: OffsetDateTime, + var channelUrl: String, + var content: List, + var channel: IPlatformChannel? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt new file mode 100644 index 00000000..6618c3cb --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt @@ -0,0 +1,17 @@ +package com.futo.platformplayer.subsexchange + +import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer +import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import kotlinx.serialization.Serializable +import java.time.OffsetDateTime + +@Serializable +class ExchangeContract( + var id: String, + var requests: List, + var provided: List = listOf(), + var required: List = listOf(), + @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + var expired: OffsetDateTime = OffsetDateTime.MIN, + var contractVersion: Int = 1 +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt new file mode 100644 index 00000000..30550d51 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt @@ -0,0 +1,10 @@ +package com.futo.platformplayer.subsexchange + +import kotlinx.serialization.Serializable + +@Serializable +data class ExchangeContractResolve( + val publicKey: String, + val signature: String, + val data: String +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt new file mode 100644 index 00000000..2fcfaf3f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt @@ -0,0 +1,118 @@ +import com.futo.platformplayer.subsexchange.ChannelRequest +import com.futo.platformplayer.subsexchange.ChannelResolve +import com.futo.platformplayer.subsexchange.ChannelResult +import com.futo.platformplayer.subsexchange.ExchangeContract +import com.futo.platformplayer.subsexchange.ExchangeContractResolve +import kotlinx.serialization.* +import kotlinx.serialization.json.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.PublicKey +import java.security.Signature +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.util.Base64 +import java.io.InputStreamReader +import java.io.OutputStream +import java.io.OutputStreamWriter +import java.nio.charset.StandardCharsets +import java.security.KeyPairGenerator +import java.security.spec.PKCS8EncodedKeySpec + + +class SubsExchangeClient(private val server: String, private val privateKey: String) { + + private val publicKey: String = extractPublicKey(privateKey) + + // Endpoints + + // Endpoint: Contract + fun requestContract(vararg channels: ChannelRequest): ExchangeContract { + val data = post("/api/Channel/Contract", Json.encodeToString(channels), "application/json") + return Json.decodeFromString(data) + } + suspend fun requestContractAsync(vararg channels: ChannelRequest): ExchangeContract { + val data = postAsync("/api/Channel/Contract", Json.encodeToString(channels), "application/json") + return Json.decodeFromString(data) + } + + // Endpoint: Resolve + fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { + val contractResolve = convertResolves(*resolves) + val result = post("/api/Channel/Resolve?contractId=${contract.id}", Json.encodeToString(contractResolve), "application/json") + return Json.decodeFromString(result) + } + suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { + val contractResolve = convertResolves(*resolves) + val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Json.encodeToString(contractResolve), "application/json") + return Json.decodeFromString(result) + } + + + private fun convertResolves(vararg resolves: ChannelResolve): ExchangeContractResolve { + val data = Json.encodeToString(resolves) + val signature = createSignature(data, privateKey) + + return ExchangeContractResolve( + publicKey = publicKey, + signature = signature, + data = data + ) + } + + // IO methods + private fun post(query: String, body: String, contentType: String): String { + val url = URL("$server$query") + with(url.openConnection() as HttpURLConnection) { + requestMethod = "POST" + setRequestProperty("Content-Type", contentType) + doOutput = true + OutputStreamWriter(outputStream, StandardCharsets.UTF_8).use { it.write(body) } + + InputStreamReader(inputStream, StandardCharsets.UTF_8).use { + return it.readText() + } + } + } + private suspend fun postAsync(query: String, body: String, contentType: String): String { + return withContext(Dispatchers.IO) { + post(query, body, contentType) + } + } + + // Crypto methods + companion object { + fun createPrivateKey(): String { + val rsa = KeyFactory.getInstance("RSA") + val keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + val keyPair = keyPairGenerator.generateKeyPair(); + return Base64.getEncoder().encodeToString(keyPair.private.encoded); + } + + fun extractPublicKey(privateKey: String): String { + val keySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey)) + val keyFactory = KeyFactory.getInstance("RSA") + val privateKeyObj = keyFactory.generatePrivate(keySpec) as RSAPrivateKey + val publicKeyObj: RSAPublicKey = keyFactory.generatePublic(keySpec) as RSAPublicKey; + return Base64.getEncoder().encodeToString(publicKeyObj.encoded) + } + + fun createSignature(data: String, privateKey: String): String { + val keySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey)) + val keyFactory = KeyFactory.getInstance("RSA") + val rsaPrivateKey = keyFactory.generatePrivate(keySpec) as RSAPrivateKey + + val signature = Signature.getInstance("SHA256withRSA") + signature.initSign(rsaPrivateKey) + signature.update(data.toByteArray(Charsets.UTF_8)) + + val signatureBytes = signature.sign() + return Base64.getEncoder().encodeToString(signatureBytes) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_downloads.xml b/app/src/main/res/layout/fragment_downloads.xml index 2b971a0b..c1b12c0b 100644 --- a/app/src/main/res/layout/fragment_downloads.xml +++ b/app/src/main/res/layout/fragment_downloads.xml @@ -168,7 +168,7 @@ android:layout_height="wrap_content" android:layout_marginTop="10dp" android:background="@drawable/background_button_round" - android:hint="Seach.." /> + android:hint="Search.." /> Optimize bandwidth usage by switching to audio-only stream in background if available, may cause stutter Groups Show Subscription Groups + Use Subscription Exchange (Experimental) + Uses a centralized crowd-sourced server to significantly reduce the required requests for subscriptions, in exchange you submit your subscriptions to the server. If subscription groups should be shown above your subscriptions to filter Preview Feed Items When the preview feedstyle is used, if items should auto-preview when scrolling over them From 3c05521a5b6986a8f2967d725cbb2d659afafc16 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Thu, 27 Mar 2025 23:25:13 +0100 Subject: [PATCH 06/19] Chapter Overlay --- .../mainactivity/main/VideoDetailView.kt | 33 ++++++ .../views/adapters/ChapterViewHolder.kt | 91 ++++++++++++++++ .../views/overlays/ChaptersOverlay.kt | 72 ++++++++++++ .../views/segments/ChaptersList.kt | 103 ++++++++++++++++++ .../main/res/layout/fragview_video_detail.xml | 6 + app/src/main/res/layout/list_chapter.xml | 81 ++++++++++++++ app/src/main/res/layout/overlay_chapters.xml | 34 ++++++ .../main/res/layout/view_chapters_list.xml | 31 ++++++ app/src/main/res/values/strings.xml | 1 + 9 files changed, 452 insertions(+) create mode 100644 app/src/main/java/com/futo/platformplayer/views/adapters/ChapterViewHolder.kt create mode 100644 app/src/main/java/com/futo/platformplayer/views/overlays/ChaptersOverlay.kt create mode 100644 app/src/main/java/com/futo/platformplayer/views/segments/ChaptersList.kt create mode 100644 app/src/main/res/layout/list_chapter.xml create mode 100644 app/src/main/res/layout/overlay_chapters.xml create mode 100644 app/src/main/res/layout/view_chapters_list.xml 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 8d930838..db5c6060 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 @@ -132,6 +132,7 @@ import com.futo.platformplayer.views.behavior.TouchInterceptFrameLayout import com.futo.platformplayer.views.casting.CastView import com.futo.platformplayer.views.comments.AddCommentView import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.overlays.ChaptersOverlay import com.futo.platformplayer.views.overlays.DescriptionOverlay import com.futo.platformplayer.views.overlays.LiveChatOverlay import com.futo.platformplayer.views.overlays.QueueEditorOverlay @@ -147,6 +148,7 @@ import com.futo.platformplayer.views.pills.PillRatingLikesDislikes import com.futo.platformplayer.views.pills.RoundButton import com.futo.platformplayer.views.pills.RoundButtonGroup import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.segments.ChaptersList import com.futo.platformplayer.views.segments.CommentsList import com.futo.platformplayer.views.subscriptions.SubscribeButton import com.futo.platformplayer.views.video.FutoVideoPlayer @@ -195,6 +197,8 @@ class VideoDetailView : ConstraintLayout { private var _liveChat: LiveChatManager? = null; private var _videoResumePositionMilliseconds : Long = 0L; + private var _chapters: List? = null; + private val _player: FutoVideoPlayer; private val _cast: CastView; private val _playerProgress: PlayerControlView; @@ -263,6 +267,7 @@ class VideoDetailView : ConstraintLayout { private val _container_content_liveChat: LiveChatOverlay; private val _container_content_browser: WebviewOverlay; private val _container_content_support: SupportOverlay; + private val _container_content_chapters: ChaptersOverlay; private var _container_content_current: View; @@ -374,6 +379,7 @@ class VideoDetailView : ConstraintLayout { _container_content_liveChat = findViewById(R.id.videodetail_container_livechat); _container_content_support = findViewById(R.id.videodetail_container_support); _container_content_browser = findViewById(R.id.videodetail_container_webview) + _container_content_chapters = findViewById(R.id.videodetail_container_chapters); _addCommentView = findViewById(R.id.add_comment_view); _commentsList = findViewById(R.id.comments_list); @@ -686,6 +692,11 @@ class VideoDetailView : ConstraintLayout { _container_content_replies.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_support.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_browser.onClose.subscribe { switchContentView(_container_content_main); }; + _container_content_chapters.onClose.subscribe { switchContentView(_container_content_main); }; + + _container_content_chapters.onClick.subscribe { + handleSeek(it.timeStart.toLong() * 1000); + } _description_viewMore.setOnClickListener { switchContentView(_container_content_description); @@ -865,6 +876,21 @@ class VideoDetailView : ConstraintLayout { }; } }, + _chapters?.let { + if(it != null && it.size > 0) + RoundButton(context, R.drawable.ic_list, "Chapters", TAG_CHAPTERS) { + video?.let { + try { + _container_content_chapters.setChapters(_chapters); + switchContentView(_container_content_chapters); + } + catch(ex: Throwable) { + + } + } + } + else null + }, if(video?.isLive ?: false) RoundButton(context, R.drawable.ic_chat, context.getString(R.string.live_chat), TAG_LIVECHAT) { video?.let { @@ -1340,10 +1366,12 @@ class VideoDetailView : ConstraintLayout { val chapters = null ?: StatePlatform.instance.getContentChapters(video.url); _player.setChapters(chapters); _cast.setChapters(chapters); + _chapters = _player.getChapters(); } catch (ex: Throwable) { Logger.e(TAG, "Failed to get chapters", ex); _player.setChapters(null); _cast.setChapters(null); + _chapters = null; /*withContext(Dispatchers.Main) { UIDialogs.toast(context, "Failed to get chapters\n" + ex.message); @@ -1382,6 +1410,10 @@ class VideoDetailView : ConstraintLayout { ); } } + + fragment.lifecycleScope.launch(Dispatchers.Main) { + updateMoreButtons(); + } }; } @@ -3077,6 +3109,7 @@ class VideoDetailView : ConstraintLayout { const val TAG_SHARE = "share"; const val TAG_OVERLAY = "overlay"; const val TAG_LIVECHAT = "livechat"; + const val TAG_CHAPTERS = "chapters"; const val TAG_OPEN = "open"; const val TAG_SEND_TO_DEVICE = "send_to_device"; const val TAG_MORE = "MORE"; diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ChapterViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ChapterViewHolder.kt new file mode 100644 index 00000000..0b7efd4a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ChapterViewHolder.kt @@ -0,0 +1,91 @@ +package com.futo.platformplayer.views.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.api.media.models.chapters.ChapterType +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.comments.LazyComment +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.fixHtmlLinks +import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.toHumanDuration +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.toHumanTime +import com.futo.platformplayer.views.LoaderView +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.pills.PillButton +import com.futo.platformplayer.views.pills.PillRatingLikesDislikes +import com.futo.polycentric.core.ApiMethods +import com.futo.polycentric.core.Opinion +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ChapterViewHolder : ViewHolder { + + private val _layoutChapter: ConstraintLayout; + + private val _containerChapter: ConstraintLayout; + + private val _textTitle: TextView; + private val _textTimestamp: TextView; + private val _textMeta: TextView; + + var onClick = Event1(); + var chapter: IChapter? = null + private set; + + constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_chapter, viewGroup, false)) { + _layoutChapter = itemView.findViewById(R.id.layout_chapter); + _containerChapter = itemView.findViewById(R.id.chapter_container); + + _containerChapter.setOnClickListener { + chapter?.let { + onClick.emit(it); + } + } + + _textTitle = itemView.findViewById(R.id.text_title); + _textTimestamp = itemView.findViewById(R.id.text_timestamp); + _textMeta = itemView.findViewById(R.id.text_meta); + } + + fun bind(chapter: IChapter) { + _textTitle.text = chapter.name; + _textTimestamp.text = chapter.timeStart.toLong().toHumanTime(false); + + if(chapter.type == ChapterType.NORMAL) { + _textMeta.isVisible = false; + } + else { + _textMeta.isVisible = true; + when(chapter.type) { + ChapterType.SKIP -> _textMeta.text = "(Skip)"; + ChapterType.SKIPPABLE -> _textMeta.text = "(Manual Skip)" + ChapterType.SKIPONCE -> _textMeta.text = "(Skip Once)" + else -> _textMeta.isVisible = false; + }; + } + this.chapter = chapter; + } + + companion object { + private const val TAG = "CommentViewHolder"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/ChaptersOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/ChaptersOverlay.kt new file mode 100644 index 00000000..becdb87b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/ChaptersOverlay.kt @@ -0,0 +1,72 @@ +package com.futo.platformplayer.views.overlays + +import android.content.Context +import android.net.Uri +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.api.http.ManagedHttpClient +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.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.fixHtmlLinks +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.views.behavior.NonScrollingTextView +import com.futo.platformplayer.views.comments.AddCommentView +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.segments.ChaptersList +import com.futo.platformplayer.views.segments.CommentsList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import userpackage.Protocol + +class ChaptersOverlay : LinearLayout { + val onClose = Event0(); + val onClick = Event1(); + + private val _topbar: OverlayTopbar; + private val _chaptersList: ChaptersList; + private var _onChapterClicked: ((chapter: IChapter) -> Unit)? = null; + private val _layoutItems: LinearLayout + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.overlay_chapters, this) + _layoutItems = findViewById(R.id.layout_items) + _topbar = findViewById(R.id.topbar); + _chaptersList = findViewById(R.id.chapters_list); + _chaptersList.onChapterClick.subscribe(onClick::emit); + _topbar.onClose.subscribe(this, onClose::emit); + _topbar.setInfo(context.getString(R.string.chapters), ""); + } + + fun setChapters(chapters: List?) { + _chaptersList?.setChapters(chapters ?: listOf()); + } + + + fun cleanup() { + _topbar.onClose.remove(this); + _onChapterClicked = null; + } + + companion object { + private const val TAG = "ChaptersOverlay" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/segments/ChaptersList.kt b/app/src/main/java/com/futo/platformplayer/views/segments/ChaptersList.kt new file mode 100644 index 00000000..50d7ba04 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/segments/ChaptersList.kt @@ -0,0 +1,103 @@ +package com.futo.platformplayer.views.segments + +import android.content.Context +import android.graphics.Color +import android.util.AttributeSet +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +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.comments.LazyComment +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.structures.IAsyncPager +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.views.adapters.ChapterViewHolder +import com.futo.platformplayer.views.adapters.CommentViewHolder +import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.net.UnknownHostException + +class ChaptersList : ConstraintLayout { + private val _llmReplies: LinearLayoutManager; + + private val _adapterChapters: InsertedViewAdapterWithLoader; + private val _recyclerChapters: RecyclerView; + private val _chapters: ArrayList = arrayListOf(); + private val _prependedView: FrameLayout; + private var _readonly: Boolean = false; + private val _layoutScrollToTop: FrameLayout; + + var onChapterClick = Event1(); + var onCommentsLoaded = Event1(); + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.view_chapters_list, this, true); + + _recyclerChapters = findViewById(R.id.recycler_chapters); + + _layoutScrollToTop = findViewById(R.id.layout_scroll_to_top); + _layoutScrollToTop.setOnClickListener { + _recyclerChapters.smoothScrollToPosition(0) + } + _layoutScrollToTop.visibility = View.GONE + + _prependedView = FrameLayout(context); + _prependedView.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT); + + _adapterChapters = InsertedViewAdapterWithLoader(context, arrayListOf(_prependedView), arrayListOf(), + childCountGetter = { _chapters.size }, + childViewHolderBinder = { viewHolder, position -> viewHolder.bind(_chapters[position]); }, + childViewHolderFactory = { viewGroup, _ -> + val holder = ChapterViewHolder(viewGroup); + holder.onClick.subscribe { c -> onChapterClick.emit(c) }; + return@InsertedViewAdapterWithLoader holder; + } + ); + + _llmReplies = LinearLayoutManager(context); + _recyclerChapters.layoutManager = _llmReplies; + _recyclerChapters.adapter = _adapterChapters; + } + + fun addChapter(chapter: IChapter) { + _chapters.add(0, chapter); + _adapterChapters.notifyItemRangeInserted(_adapterChapters.childToParentPosition(0), 1); + } + + fun setPrependedView(view: View) { + _prependedView.removeAllViews(); + _prependedView.addView(view); + } + + fun setChapters(chapters: List) { + _chapters.clear(); + _chapters.addAll(chapters); + _adapterChapters.notifyDataSetChanged(); + } + + fun clear() { + _chapters.clear(); + _adapterChapters.notifyDataSetChanged(); + } + + companion object { + private const val TAG = "CommentsList"; + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragview_video_detail.xml b/app/src/main/res/layout/fragview_video_detail.xml index d5062c06..7b8c1dc2 100644 --- a/app/src/main/res/layout/fragview_video_detail.xml +++ b/app/src/main/res/layout/fragview_video_detail.xml @@ -579,6 +579,12 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/overlay_chapters.xml b/app/src/main/res/layout/overlay_chapters.xml new file mode 100644 index 00000000..6664847c --- /dev/null +++ b/app/src/main/res/layout/overlay_chapters.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_chapters_list.xml b/app/src/main/res/layout/view_chapters_list.xml new file mode 100644 index 00000000..8fda13f3 --- /dev/null +++ b/app/src/main/res/layout/view_chapters_list.xml @@ -0,0 +1,31 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5b1b6d9e..256fe1ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -665,6 +665,7 @@ Failed to load post. replies Replies + Chapters Plugin settings saved Plugin settings These settings are defined by the plugin From 5a9fcd6fab10b2895ce031d3b1cfed8b120decf4 Mon Sep 17 00:00:00 2001 From: Stefan <84-stefancruz@users.noreply.gitlab.futo.org> Date: Fri, 28 Mar 2025 19:13:55 +0000 Subject: [PATCH 07/19] add ted talks plugin --- .gitmodules | 6 ++++++ app/src/stable/assets/sources/tedtalks | 1 + app/src/stable/res/raw/plugin_config.json | 3 ++- app/src/unstable/assets/sources/tedtalks | 1 + app/src/unstable/res/raw/plugin_config.json | 3 ++- 5 files changed, 12 insertions(+), 2 deletions(-) create mode 160000 app/src/stable/assets/sources/tedtalks create mode 160000 app/src/unstable/assets/sources/tedtalks diff --git a/.gitmodules b/.gitmodules index 5f6ab0dc..c906834c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -88,3 +88,9 @@ [submodule "app/src/unstable/assets/sources/apple-podcasts"] path = app/src/unstable/assets/sources/apple-podcasts url = ../plugins/apple-podcasts.git +[submodule "app/src/stable/assets/sources/tedtalks"] + path = app/src/stable/assets/sources/tedtalks + url = ../plugins/tedtalks.git +[submodule "app/src/unstable/assets/sources/tedtalks"] + path = app/src/unstable/assets/sources/tedtalks + url = ../plugins/tedtalks.git diff --git a/app/src/stable/assets/sources/tedtalks b/app/src/stable/assets/sources/tedtalks new file mode 160000 index 00000000..4e490737 --- /dev/null +++ b/app/src/stable/assets/sources/tedtalks @@ -0,0 +1 @@ +Subproject commit 4e490737a02491b52611af321582af8bead7d506 diff --git a/app/src/stable/res/raw/plugin_config.json b/app/src/stable/res/raw/plugin_config.json index d98fc987..2b851fa5 100644 --- a/app/src/stable/res/raw/plugin_config.json +++ b/app/src/stable/res/raw/plugin_config.json @@ -13,7 +13,8 @@ "4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json", "9c87e8db-e75d-48f4-afe5-2d203d4b95c5": "sources/dailymotion/build/DailymotionConfig.json", "e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json", - "89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json" + "89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json", + "8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json" }, "SOURCES_EMBEDDED_DEFAULT": [ "35ae969a-a7db-11ed-afa1-0242ac120002" diff --git a/app/src/unstable/assets/sources/tedtalks b/app/src/unstable/assets/sources/tedtalks new file mode 160000 index 00000000..4e490737 --- /dev/null +++ b/app/src/unstable/assets/sources/tedtalks @@ -0,0 +1 @@ +Subproject commit 4e490737a02491b52611af321582af8bead7d506 diff --git a/app/src/unstable/res/raw/plugin_config.json b/app/src/unstable/res/raw/plugin_config.json index cfbf3e87..bdea11d0 100644 --- a/app/src/unstable/res/raw/plugin_config.json +++ b/app/src/unstable/res/raw/plugin_config.json @@ -13,7 +13,8 @@ "4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json", "9c87e8db-e75d-48f4-afe5-2d203d4b95c5": "sources/dailymotion/build/DailymotionConfig.json", "e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json", - "89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json" + "89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json", + "8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json" }, "SOURCES_EMBEDDED_DEFAULT": [ "35ae969a-a7db-11ed-afa1-0242ac120002" From b652597924aa053c11493b98713babc66b74d052 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Fri, 28 Mar 2025 21:40:17 +0100 Subject: [PATCH 08/19] chapters ui on text press --- .../mainactivity/main/VideoDetailView.kt | 30 +++++++++++++------ .../views/video/FutoVideoPlayer.kt | 8 +++++ 2 files changed, 29 insertions(+), 9 deletions(-) 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 db5c6060..47ac35d2 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 @@ -404,6 +404,10 @@ class VideoDetailView : ConstraintLayout { _monetization = findViewById(R.id.monetization); _player.attachPlayer(); + _player.onChapterClicked.subscribe { + showChaptersUI(); + }; + _buttonSubscribe.onSubscribed.subscribe { _slideUpOverlay = UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer); @@ -863,6 +867,22 @@ class VideoDetailView : ConstraintLayout { _cast.stopAllGestures(); } + fun showChaptersUI(){ + video?.let { + try { + _chapters?.let { + if(it.size == 0) + return@let; + _container_content_chapters.setChapters(_chapters); + switchContentView(_container_content_chapters); + } + } + catch(ex: Throwable) { + + } + } + } + fun updateMoreButtons() { val isLimitedVersion = video?.url != null && StatePlatform.instance.getContentClientOrNull(video!!.url)?.let { if (it is JSClient) @@ -879,15 +899,7 @@ class VideoDetailView : ConstraintLayout { _chapters?.let { if(it != null && it.size > 0) RoundButton(context, R.drawable.ic_list, "Chapters", TAG_CHAPTERS) { - video?.let { - try { - _container_content_chapters.setChapters(_chapters); - switchContentView(_container_content_chapters); - } - catch(ex: Throwable) { - - } - } + showChaptersUI(); } else null }, diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt index ec0345fb..1daa7808 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt @@ -145,6 +145,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase { val onVideoClicked = Event0(); val onTimeBarChanged = Event2(); + val onChapterClicked = Event1(); + @OptIn(UnstableApi::class) constructor(context: Context, attrs: AttributeSet? = null) : super(PLAYER_STATE_NAME, context, attrs) { LayoutInflater.from(context).inflate(R.layout.video_view, this, true); @@ -185,6 +187,12 @@ class FutoVideoPlayer : FutoVideoPlayerBase { _control_duration_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_duration); _control_pause_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_pause); + _control_chapter.setOnClickListener { + _currentChapter?.let { + onChapterClicked.emit(it); + } + } + val castVisibility = if (Settings.instance.casting.enabled) View.VISIBLE else View.GONE _control_cast.visibility = castVisibility _control_cast_fullscreen.visibility = castVisibility From 7f7ebafa461ba1daada79080d4ffdc1316efe95e Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Mon, 31 Mar 2025 20:02:49 +0200 Subject: [PATCH 09/19] Resume on playback error instead of reseting, dont error on empty author url, subs exchange fixes --- .../fragment/mainactivity/main/VideoDetailView.kt | 7 +++++-- .../subscription/SubscriptionsTaskFetchAlgorithm.kt | 9 ++++----- 2 files changed, 9 insertions(+), 7 deletions(-) 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 47ac35d2..25b03cb1 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 @@ -1907,7 +1907,7 @@ class VideoDetailView : ConstraintLayout { else null; withContext(Dispatchers.Main) { video = newDetails; - _player.setSource(newVideoSource, newAudioSource, true, true); + _player.setSource(newVideoSource, newAudioSource, true, true, true); } } } catch (e: Throwable) { @@ -2645,7 +2645,10 @@ class VideoDetailView : ConstraintLayout { } onChannelClicked.subscribe { - fragment.navigate(it) + if(it.url.isNotBlank()) + fragment.navigate(it) + else + UIDialogs.appToast("No author url present"); } onAddToWatchLaterClicked.subscribe(this) { diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt index dfec6fdf..630cef65 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -15,7 +15,6 @@ import com.futo.platformplayer.api.media.structures.PlatformContentPager import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCriticalException -import com.futo.platformplayer.engine.exceptions.ScriptException import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.findNonRuntimeException import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment @@ -77,10 +76,10 @@ abstract class SubscriptionsTaskFetchAlgorithm( - val liveTasks = tasks.filter { !it.fromPeek && !it.fromCache }; - val contract = subsExchangeClient?.requestContract(*liveTasks.map { ChannelRequest(it.url) }.toTypedArray()); + val contractableTasks = tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) }; + val contract = if(contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map { ChannelRequest(it.url) }.toTypedArray()) else null; var providedTasks: MutableList? = null; - if(contract != null && contract.provided.size > 0){ + if(contract != null && contract.provided.isNotEmpty()){ providedTasks = mutableListOf() for(task in tasks.toList()){ if(!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) { @@ -130,7 +129,7 @@ abstract class SubscriptionsTaskFetchAlgorithm( try { val resolve = subsExchangeClient?.resolveContract( contract, - *taskResults.filter { it.pager != null }.map { + *taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) }.map { ChannelResolve( it.task.url, it.pager!!.getResults() From c1993ffa0367aa55bcdc8790bafd7a65292f9bc5 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Tue, 1 Apr 2025 00:56:24 +0200 Subject: [PATCH 10/19] SubsExchange fixes --- .../media/models/contents/IPlatformContent.kt | 1 + .../models/video/SerializedPlatformVideo.kt | 3 +- .../platformplayer/models/HistoryVideo.kt | 2 + .../serializers/OffsetDateTimeSerializer.kt | 12 +++++ .../states/StateSubscriptions.kt | 11 +++- .../SubscriptionsTaskFetchAlgorithm.kt | 22 +++++--- .../subsexchange/ChannelRequest.kt | 4 +- .../subsexchange/ChannelResolve.kt | 8 ++- .../subsexchange/ChannelResult.kt | 8 ++- .../subsexchange/ExchangeContract.kt | 13 ++++- .../subsexchange/ExchangeContractResolve.kt | 4 ++ .../subsexchange/SubsExchangeClient.kt | 53 +++++++++++++++---- .../platformplayer/RequireMigrationTests.kt | 2 + 13 files changed, 119 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt index 554a4723..edb1caa3 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt @@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.contents import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import kotlinx.serialization.Serializable import java.time.OffsetDateTime interface IPlatformContent { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt index 49b6265b..68bb5cb9 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt @@ -14,6 +14,7 @@ import java.time.OffsetDateTime @kotlinx.serialization.Serializable open class SerializedPlatformVideo( + override val contentType: ContentType = ContentType.MEDIA, override val id: PlatformID, override val name: String, override val thumbnails: Thumbnails, @@ -27,7 +28,6 @@ open class SerializedPlatformVideo( override val viewCount: Long, override val isShort: Boolean = false ) : IPlatformVideo, SerializedPlatformContent { - override val contentType: ContentType = ContentType.MEDIA; override val isLive: Boolean = false; @@ -44,6 +44,7 @@ open class SerializedPlatformVideo( companion object { fun fromVideo(video: IPlatformVideo) : SerializedPlatformVideo { return SerializedPlatformVideo( + ContentType.MEDIA, video.id, video.name, video.thumbnails, diff --git a/app/src/main/java/com/futo/platformplayer/models/HistoryVideo.kt b/app/src/main/java/com/futo/platformplayer/models/HistoryVideo.kt index b491f95f..80574968 100644 --- a/app/src/main/java/com/futo/platformplayer/models/HistoryVideo.kt +++ b/app/src/main/java/com/futo/platformplayer/models/HistoryVideo.kt @@ -3,6 +3,7 @@ package com.futo.platformplayer.models import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.serializers.OffsetDateTimeSerializer import java.time.LocalDateTime @@ -46,6 +47,7 @@ class HistoryVideo { val name = str.substring(indexNext + 3); val video = resolve?.invoke(url) ?: SerializedPlatformVideo( + ContentType.MEDIA, id = PlatformID.asUrlID(url), name = name, thumbnails = Thumbnails(), diff --git a/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt b/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt index 31fbaadd..faee4e3b 100644 --- a/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt +++ b/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt @@ -39,4 +39,16 @@ class OffsetDateTimeSerializer : KSerializer { return OffsetDateTime.MIN; return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC); } +} +class OffsetDateTimeStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("OffsetDateTime", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: OffsetDateTime) { + encoder.encodeString(value.toString()); + } + override fun deserialize(decoder: Decoder): OffsetDateTime { + val str = decoder.decodeString(); + + return OffsetDateTime.parse(str); + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt index f0a56b41..1d1acff6 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -375,7 +375,16 @@ class StateSubscriptions { } fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null, subGroup: SubscriptionGroup? = null): Pair, List> { - val exchangeClient = if(Settings.instance.subscriptions.useSubscriptionExchange) getSubsExchangeClient() else null; + var exchangeClient: SubsExchangeClient? = null; + if(Settings.instance.subscriptions.useSubscriptionExchange) { + try { + exchangeClient = getSubsExchangeClient(); + } + catch(ex: Throwable){ + Logger.e(TAG, "Failed to get subs exchange client: ${ex.message}", ex); + } + } + val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool, exchangeClient); if(onNewCacheHit != null) algo.onNewCacheHit.subscribe(onNewCacheHit) diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt index 630cef65..15235017 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -5,6 +5,9 @@ import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent +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.DedupContentPager @@ -78,8 +81,10 @@ abstract class SubscriptionsTaskFetchAlgorithm( val contractableTasks = tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) }; val contract = if(contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map { ChannelRequest(it.url) }.toTypedArray()) else null; + if(contract?.provided?.isNotEmpty() == true) + Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}"); var providedTasks: MutableList? = null; - if(contract != null && contract.provided.isNotEmpty()){ + if(contract != null && contract.required.isNotEmpty()){ providedTasks = mutableListOf() for(task in tasks.toList()){ if(!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) { @@ -127,16 +132,18 @@ abstract class SubscriptionsTaskFetchAlgorithm( //Resolve Subscription Exchange if(contract != null) { try { + val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract.required.contains(it.task.url) }.map { + ChannelResolve( + it.task.url, + it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) } + ) + }.toTypedArray() val resolve = subsExchangeClient?.resolveContract( contract, - *taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) }.map { - ChannelResolve( - it.task.url, - it.pager!!.getResults() - ) - }.toTypedArray() + *resolves ); if (resolve != null) { + UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size})") for(result in resolve){ val task = providedTasks?.find { it.url == result.channelUrl }; if(task != null) { @@ -153,6 +160,7 @@ abstract class SubscriptionsTaskFetchAlgorithm( } catch(ex: Throwable) { //TODO: fetch remainder after all? + Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex); } } diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt index cde158d5..a7939ae4 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt @@ -1,8 +1,10 @@ package com.futo.platformplayer.subsexchange +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable class ChannelRequest( - var url: String + @SerialName("ChannelUrl") + var channelUrl: String ); \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt index eaedc191..7bf5e022 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt @@ -2,12 +2,18 @@ package com.futo.platformplayer.subsexchange import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.time.OffsetDateTime @Serializable class ChannelResolve( + @SerialName("ChannelUrl") var channelUrl: String, - var content: List, + @SerialName("Content") + var content: List, + @SerialName("Channel") var channel: IPlatformChannel? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt index f55f2451..c13f101c 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt @@ -2,16 +2,22 @@ package com.futo.platformplayer.subsexchange import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.time.OffsetDateTime @Serializable class ChannelResult( @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + @SerialName("DateTime") var dateTime: OffsetDateTime, + @SerialName("ChannelUrl") var channelUrl: String, - var content: List, + @SerialName("Content") + var content: List, + @SerialName("Channel") var channel: IPlatformChannel? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt index 6618c3cb..d357c8b7 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt @@ -2,16 +2,27 @@ package com.futo.platformplayer.subsexchange import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import com.futo.platformplayer.serializers.OffsetDateTimeStringSerializer +import com.google.gson.annotations.SerializedName +import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.Serializer import java.time.OffsetDateTime @Serializable class ExchangeContract( + @SerialName("ID") var id: String, + @SerialName("Requests") var requests: List, + @SerialName("Provided") var provided: List = listOf(), + @SerialName("Required") var required: List = listOf(), - @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + @SerialName("Expire") + @kotlinx.serialization.Serializable(with = OffsetDateTimeStringSerializer::class) var expired: OffsetDateTime = OffsetDateTime.MIN, + @SerialName("ContractVersion") var contractVersion: Int = 1 ) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt index 30550d51..8f42e0c3 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt @@ -1,10 +1,14 @@ package com.futo.platformplayer.subsexchange +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class ExchangeContractResolve( + @SerialName("PublicKey") val publicKey: String, + @SerialName("Signature") val signature: String, + @SerialName("Data") val data: String ) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt index 2fcfaf3f..a58e17b0 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt @@ -1,3 +1,5 @@ +import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.subsexchange.ChannelRequest import com.futo.platformplayer.subsexchange.ChannelResolve import com.futo.platformplayer.subsexchange.ChannelResult @@ -19,13 +21,19 @@ import java.util.Base64 import java.io.InputStreamReader import java.io.OutputStream import java.io.OutputStreamWriter +import java.math.BigInteger import java.nio.charset.StandardCharsets import java.security.KeyPairGenerator import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.RSAPublicKeySpec class SubsExchangeClient(private val server: String, private val privateKey: String) { + private val json = Json { + ignoreUnknownKeys = true + } + private val publicKey: String = extractPublicKey(privateKey) // Endpoints @@ -43,18 +51,18 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str // Endpoint: Resolve fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { val contractResolve = convertResolves(*resolves) - val result = post("/api/Channel/Resolve?contractId=${contract.id}", Json.encodeToString(contractResolve), "application/json") - return Json.decodeFromString(result) + val result = post("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json") + return Serializer.json.decodeFromString(result) } suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { val contractResolve = convertResolves(*resolves) - val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Json.encodeToString(contractResolve), "application/json") - return Json.decodeFromString(result) + val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json") + return Serializer.json.decodeFromString(result) } private fun convertResolves(vararg resolves: ChannelResolve): ExchangeContractResolve { - val data = Json.encodeToString(resolves) + val data = Serializer.json.encodeToString(resolves) val signature = createSignature(data, privateKey) return ExchangeContractResolve( @@ -66,15 +74,31 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str // IO methods private fun post(query: String, body: String, contentType: String): String { - val url = URL("$server$query") + val url = URL("${server.trim('/')}$query") with(url.openConnection() as HttpURLConnection) { requestMethod = "POST" setRequestProperty("Content-Type", contentType) doOutput = true - OutputStreamWriter(outputStream, StandardCharsets.UTF_8).use { it.write(body) } + OutputStreamWriter(outputStream, StandardCharsets.UTF_8).use { it.write(body); it.flush() } + + val status = responseCode; + Logger.i("SubsExchangeClient", "POST [${url}]: ${status}"); + + if(status == 200) + InputStreamReader(inputStream, StandardCharsets.UTF_8).use { + return it.readText() + } + else { + var errorStr = ""; + try { + errorStr = InputStreamReader(errorStream, StandardCharsets.UTF_8).use { + return@use it.readText() + } + } + catch(ex: Throwable){} + + throw Exception("Exchange server resulted in code ${status}:\n" + errorStr); - InputStreamReader(inputStream, StandardCharsets.UTF_8).use { - return it.readText() } } } @@ -98,8 +122,15 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str val keySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey)) val keyFactory = KeyFactory.getInstance("RSA") val privateKeyObj = keyFactory.generatePrivate(keySpec) as RSAPrivateKey - val publicKeyObj: RSAPublicKey = keyFactory.generatePublic(keySpec) as RSAPublicKey; - return Base64.getEncoder().encodeToString(publicKeyObj.encoded) + val publicKeyObj: PublicKey? = keyFactory.generatePublic(RSAPublicKeySpec(privateKeyObj.modulus, BigInteger.valueOf(65537))); + var publicKeyBase64 = Base64.getEncoder().encodeToString(publicKeyObj?.encoded); + var pem = "-----BEGIN PUBLIC KEY-----" + while(publicKeyBase64.length > 0) { + val length = Math.min(publicKeyBase64.length, 64); + pem += "\n" + publicKeyBase64.substring(0, length); + publicKeyBase64 = publicKeyBase64.substring(length); + } + return pem + "\n-----END PUBLIC KEY-----"; } fun createSignature(data: String, privateKey: String): String { diff --git a/app/src/test/java/com/futo/platformplayer/RequireMigrationTests.kt b/app/src/test/java/com/futo/platformplayer/RequireMigrationTests.kt index 1b5eabc8..3118194b 100644 --- a/app/src/test/java/com/futo/platformplayer/RequireMigrationTests.kt +++ b/app/src/test/java/com/futo/platformplayer/RequireMigrationTests.kt @@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.Thumbnail import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails import com.futo.platformplayer.serializers.FlexibleBooleanSerializer @@ -39,6 +40,7 @@ class RequireMigrationTests { val viewCount = 1000L return SerializedPlatformVideo( + ContentType.MEDIA, platformId, name, thumbnails, From b545545712a75be2e6d6606863a04e9e04be17d0 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Tue, 1 Apr 2025 01:01:45 +0200 Subject: [PATCH 11/19] Remove dep --- .../com/futo/platformplayer/subsexchange/ExchangeContract.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt index d357c8b7..41a6bf75 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt @@ -4,7 +4,6 @@ import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer import com.futo.platformplayer.serializers.OffsetDateTimeStringSerializer import com.google.gson.annotations.SerializedName -import kotlinx.datetime.LocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Serializer From dd6bde97a968ae9816cfb91298fab75073143c10 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 2 Apr 2025 22:53:54 +0200 Subject: [PATCH 12/19] Playlists sort and search support, Playlist search support, wip local playback, other fixes --- ...> DownloadedVideoMuxedSourceDescriptor.kt} | 2 +- .../api/media/platforms/local/LocalClient.kt | 5 + .../local/models/LocalVideoDetails.kt | 85 ++++++++++++++ .../models/LocalVideoMuxedSourceDescriptor.kt | 13 +++ .../platforms/local/models/MediaStoreVideo.kt | 25 +++++ .../models/sources/LocalVideoFileSource.kt | 31 ++++++ .../platformplayer/downloads/VideoLocal.kt | 4 +- .../mainactivity/main/DownloadsFragment.kt | 2 +- .../mainactivity/main/PlaylistsFragment.kt | 79 ++++++++++++- .../mainactivity/main/VideoListEditorView.kt | 43 ++++++- .../platformplayer/helpers/VideoHelper.kt | 33 ++++++ .../platformplayer/models/ImageVariable.kt | 7 ++ .../SubscriptionsTaskFetchAlgorithm.kt | 34 ++++-- .../views/overlays/ImageVariableOverlay.kt | 6 +- app/src/main/res/drawable/ic_search_off.xml | 10 ++ .../main/res/layout/fragment_playlists.xml | 105 +++++++++++++----- .../res/layout/fragment_video_list_editor.xml | 48 ++++++-- app/src/main/res/values/strings.xml | 10 ++ 18 files changed, 481 insertions(+), 61 deletions(-) rename app/src/main/java/com/futo/platformplayer/api/media/models/streams/{LocalVideoMuxedSourceDescriptor.kt => DownloadedVideoMuxedSourceDescriptor.kt} (89%) create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoDetails.kt create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoMuxedSourceDescriptor.kt create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/MediaStoreVideo.kt create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoFileSource.kt create mode 100644 app/src/main/res/drawable/ic_search_off.xml diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/LocalVideoMuxedSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/DownloadedVideoMuxedSourceDescriptor.kt similarity index 89% rename from app/src/main/java/com/futo/platformplayer/api/media/models/streams/LocalVideoMuxedSourceDescriptor.kt rename to app/src/main/java/com/futo/platformplayer/api/media/models/streams/DownloadedVideoMuxedSourceDescriptor.kt index b5309931..df96de13 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/LocalVideoMuxedSourceDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/DownloadedVideoMuxedSourceDescriptor.kt @@ -3,7 +3,7 @@ package com.futo.platformplayer.api.media.models.streams import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.downloads.VideoLocal -class LocalVideoMuxedSourceDescriptor( +class DownloadedVideoMuxedSourceDescriptor( private val video: VideoLocal ) : VideoMuxedSourceDescriptor() { override val videoSources: Array get() = video.videoSource.toTypedArray(); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt new file mode 100644 index 00000000..3f6e5b82 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt @@ -0,0 +1,5 @@ +package com.futo.platformplayer.api.media.platforms.local + +class LocalClient { + //TODO +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoDetails.kt new file mode 100644 index 00000000..1c169e64 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoDetails.kt @@ -0,0 +1,85 @@ +package com.futo.platformplayer.api.media.platforms.local.models + +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.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 +import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource +import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.downloads.VideoLocal +import java.io.File +import java.time.Instant +import java.time.OffsetDateTime +import java.time.ZoneId + +class LocalVideoDetails: IPlatformVideoDetails { + + override val contentType: ContentType get() = ContentType.UNKNOWN; + + override val id: PlatformID; + override val name: String; + override val author: PlatformAuthorLink; + + override val datetime: OffsetDateTime?; + + override val url: String; + override val shareUrl: String; + override val rating: IRating = RatingLikes(0); + override val description: String = ""; + + override val video: IVideoSourceDescriptor; + override val preview: IVideoSourceDescriptor? = null; + override val live: IVideoSource? = null; + override val dash: IDashManifestSource? = null; + override val hls: IHLSManifestSource? = null; + override val subtitles: List = listOf() + + override val thumbnails: Thumbnails; + override val duration: Long; + override val viewCount: Long = 0; + override val isLive: Boolean = false; + override val isShort: Boolean = false; + + constructor(file: File) { + id = PlatformID("Local", file.path, "LOCAL") + name = file.name; + author = PlatformAuthorLink.UNKNOWN; + + url = file.canonicalPath; + shareUrl = ""; + + duration = 0; + thumbnails = Thumbnails(arrayOf()); + + datetime = OffsetDateTime.ofInstant( + Instant.ofEpochMilli(file.lastModified()), + ZoneId.systemDefault() + ); + video = LocalVideoMuxedSourceDescriptor(LocalVideoFileSource(file)); + } + + override fun getComments(client: IPlatformClient): IPager? { + return null; + } + + override fun getPlaybackTracker(): IPlaybackTracker? { + return null; + } + + override fun getContentRecommendations(client: IPlatformClient): IPager? { + return null; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoMuxedSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoMuxedSourceDescriptor.kt new file mode 100644 index 00000000..da8ae431 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoMuxedSourceDescriptor.kt @@ -0,0 +1,13 @@ +package com.futo.platformplayer.api.media.platforms.local.models + +import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource +import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource +import com.futo.platformplayer.downloads.VideoLocal + +class LocalVideoMuxedSourceDescriptor( + private val video: LocalVideoFileSource +) : VideoMuxedSourceDescriptor() { + override val videoSources: Array get() = arrayOf(video); +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/MediaStoreVideo.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/MediaStoreVideo.kt new file mode 100644 index 00000000..52876b90 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/MediaStoreVideo.kt @@ -0,0 +1,25 @@ +package com.futo.platformplayer.api.media.platforms.local.models + +import android.content.Context +import android.database.Cursor +import android.provider.MediaStore +import android.provider.MediaStore.Video + +class MediaStoreVideo { + + + companion object { + val URI = MediaStore.Files.getContentUri("external"); + val PROJECTION = arrayOf(Video.Media._ID, Video.Media.TITLE, Video.Media.DURATION, Video.Media.HEIGHT, Video.Media.WIDTH, Video.Media.MIME_TYPE); + val ORDER = MediaStore.Video.Media.TITLE; + + fun readMediaStoreVideo(cursor: Cursor) { + + } + + fun query(context: Context, selection: String, args: Array, order: String? = null): Cursor? { + val cursor = context.contentResolver.query(URI, PROJECTION, selection, args, order ?: ORDER, null); + return cursor; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoFileSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoFileSource.kt new file mode 100644 index 00000000..9e2f7792 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoFileSource.kt @@ -0,0 +1,31 @@ +package com.futo.platformplayer.api.media.platforms.local.models.sources + +import android.content.Context +import android.provider.MediaStore +import android.provider.MediaStore.Video +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource +import com.futo.platformplayer.helpers.VideoHelper +import java.io.File + +class LocalVideoFileSource: IVideoSource { + + + override val name: String; + override val width: Int; + override val height: Int; + override val container: String; + override val codec: String = "" + override val bitrate: Int = 0 + override val duration: Long; + override val priority: Boolean = false; + + constructor(file: File) { + name = file.name; + width = 0; + height = 0; + container = VideoHelper.videoExtensionToMimetype(file.extension) ?: ""; + duration = 0; + } + +} \ No newline at end of file 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 578a5812..06095058 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt @@ -10,7 +10,7 @@ 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 -import com.futo.platformplayer.api.media.models.streams.LocalVideoMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.LocalVideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource @@ -57,7 +57,7 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem { override val video: IVideoSourceDescriptor get() = if(audioSource.isNotEmpty()) LocalVideoUnMuxedSourceDescriptor(this) else - LocalVideoMuxedSourceDescriptor(this); + DownloadedVideoMuxedSourceDescriptor(this); override val preview: IVideoSourceDescriptor? get() = videoSerialized.preview; override val live: IVideoSource? get() = videoSerialized.live; 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 440aa235..d402a6e2 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 @@ -229,7 +229,7 @@ class DownloadsFragment : MainFragment() { fun filterDownloads(vids: List): List{ var vidsToReturn = vids; if(!_listDownloadSearch.text.isNullOrEmpty()) - vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) }; + vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) || it.author.name.contains(_listDownloadSearch.text, true) }; if(!ordering.isNullOrEmpty()) { vidsToReturn = when(ordering){ "downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX }; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt index bcc01ed1..9d188415 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt @@ -6,12 +6,17 @@ import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.EditText import android.widget.FrameLayout import android.widget.ImageButton import android.widget.LinearLayout +import android.widget.Spinner import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.widget.addTextChangedListener import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -21,11 +26,13 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.views.SearchView import com.futo.platformplayer.views.adapters.* import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.google.android.material.appbar.AppBarLayout import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.time.OffsetDateTime class PlaylistsFragment : MainFragment() { @@ -65,6 +72,7 @@ class PlaylistsFragment : MainFragment() { private val _fragment: PlaylistsFragment; var watchLater: ArrayList = arrayListOf(); + var allPlaylists: ArrayList = arrayListOf(); var playlists: ArrayList = arrayListOf(); private var _appBar: AppBarLayout; private var _adapterWatchLater: VideoListHorizontalAdapter; @@ -72,12 +80,20 @@ class PlaylistsFragment : MainFragment() { private var _layoutWatchlist: ConstraintLayout; private var _slideUpOverlay: SlideUpMenuOverlay? = null; + private var _listPlaylistsSearch: EditText; + + private var _ordering: String? = null; + + constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) { _fragment = fragment; inflater.inflate(R.layout.fragment_playlists, this); + _listPlaylistsSearch = findViewById(R.id.playlists_search); + watchLater = ArrayList(); playlists = ArrayList(); + allPlaylists = ArrayList(); val recyclerWatchLater = findViewById(R.id.recycler_watch_later); @@ -105,6 +121,7 @@ class PlaylistsFragment : MainFragment() { buttonCreatePlaylist.setOnClickListener { _slideUpOverlay = UISlideOverlays.showCreatePlaylistOverlay(findViewById(R.id.overlay_create_playlist)) { val playlist = Playlist(it, arrayListOf()); + allPlaylists.add(0, playlist); playlists.add(0, playlist); StatePlaylists.instance.createOrUpdatePlaylist(playlist); @@ -120,6 +137,34 @@ class PlaylistsFragment : MainFragment() { _appBar = findViewById(R.id.app_bar); _layoutWatchlist = findViewById(R.id.layout_watchlist); + + _listPlaylistsSearch.addTextChangedListener { + updatePlaylistsFiltering(); + } + val spinnerSortBy: Spinner = findViewById(R.id.spinner_sortby); + spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.playlists_sortby_array)).also { + it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); + }; + spinnerSortBy.setSelection(0); + spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { + when(pos) { + 0 -> _ordering = "nameAsc" + 1 -> _ordering = "nameDesc" + 2 -> _ordering = "dateEditAsc" + 3 -> _ordering = "dateEditDesc" + 4 -> _ordering = "dateCreateAsc" + 5 -> _ordering = "dateCreateDesc" + 6 -> _ordering = "datePlayAsc" + 7 -> _ordering = "datePlayDesc" + else -> _ordering = null + } + updatePlaylistsFiltering() + } + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + }; + + findViewById(R.id.text_view_all).setOnClickListener { _fragment.navigate(context.getString(R.string.watch_later)); }; StatePlaylists.instance.onWatchLaterChanged.subscribe(this) { fragment.lifecycleScope.launch(Dispatchers.Main) { @@ -134,10 +179,12 @@ class PlaylistsFragment : MainFragment() { @SuppressLint("NotifyDataSetChanged") fun onShown() { + allPlaylists.clear(); playlists.clear() - playlists.addAll( + allPlaylists.addAll( StatePlaylists.instance.getPlaylists().sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) } ); + playlists.addAll(filterPlaylists(allPlaylists)); _adapterPlaylist.notifyDataSetChanged(); updateWatchLater(); @@ -157,6 +204,32 @@ class PlaylistsFragment : MainFragment() { return false; } + private fun updatePlaylistsFiltering() { + val toFilter = allPlaylists ?: return; + playlists.clear(); + playlists.addAll(filterPlaylists(toFilter)); + _adapterPlaylist.notifyDataSetChanged(); + } + private fun filterPlaylists(pls: List): List { + var playlistsToReturn = pls; + if(!_listPlaylistsSearch.text.isNullOrEmpty()) + playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) }; + if(!_ordering.isNullOrEmpty()){ + playlistsToReturn = when(_ordering){ + "nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() } + "nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() }; + "dateEditAsc" -> playlistsToReturn.sortedBy { it.dateUpdate ?: OffsetDateTime.MAX }; + "dateEditDesc" -> playlistsToReturn.sortedByDescending { it.dateUpdate ?: OffsetDateTime.MIN } + "dateCreateAsc" -> playlistsToReturn.sortedBy { it.dateCreation ?: OffsetDateTime.MAX }; + "dateCreateDesc" -> playlistsToReturn.sortedByDescending { it.dateCreation ?: OffsetDateTime.MIN } + "datePlayAsc" -> playlistsToReturn.sortedBy { it.datePlayed ?: OffsetDateTime.MAX }; + "datePlayDesc" -> playlistsToReturn.sortedByDescending { it.datePlayed ?: OffsetDateTime.MIN } + else -> playlistsToReturn + } + } + return playlistsToReturn; + } + private fun updateWatchLater() { val watchList = StatePlaylists.instance.getWatchLater(); if (watchList.isNotEmpty()) { @@ -164,7 +237,7 @@ class PlaylistsFragment : MainFragment() { _appBar.let { appBar -> val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams; - layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 230.0f, resources.displayMetrics).toInt(); + layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 315.0f, resources.displayMetrics).toInt(); appBar.layoutParams = layoutParams; } } else { @@ -172,7 +245,7 @@ class PlaylistsFragment : MainFragment() { _appBar.let { appBar -> val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams; - layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 25.0f, resources.displayMetrics).toInt(); + layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 110.0f, resources.displayMetrics).toInt(); appBar.layoutParams = layoutParams; }; } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt index b458a093..441f7421 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt @@ -9,6 +9,7 @@ import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import androidx.core.view.isVisible import androidx.core.view.setPadding import com.bumptech.glide.Glide import com.futo.platformplayer.R @@ -22,6 +23,7 @@ import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.toHumanDuration import com.futo.platformplayer.toHumanTime +import com.futo.platformplayer.views.SearchView import com.futo.platformplayer.views.lists.VideoListEditorView abstract class VideoListEditorView : LinearLayout { @@ -37,9 +39,15 @@ abstract class VideoListEditorView : LinearLayout { protected var _buttonExport: ImageButton; private var _buttonShare: ImageButton; private var _buttonEdit: ImageButton; + private var _buttonSearch: ImageButton; + + private var _search: SearchView; private var _onShare: (()->Unit)? = null; + private var _loadedVideos: List? = null; + private var _loadedVideosCanEdit: Boolean = false; + constructor(inflater: LayoutInflater) : super(inflater.context) { inflater.inflate(R.layout.fragment_video_list_editor, this); @@ -57,6 +65,26 @@ abstract class VideoListEditorView : LinearLayout { _buttonDownload.visibility = View.GONE; _buttonExport = findViewById(R.id.button_export); _buttonExport.visibility = View.GONE; + _buttonSearch = findViewById(R.id.button_search); + + _search = findViewById(R.id.search_bar); + _search.visibility = View.GONE; + _search.onSearchChanged.subscribe { + updateVideoFilters(); + } + + _buttonSearch.setOnClickListener { + if(_search.isVisible) { + _search.visibility = View.GONE; + _search.textSearch.text = ""; + updateVideoFilters(); + _buttonSearch.setImageResource(R.drawable.ic_search); + } + else { + _search.visibility = View.VISIBLE; + _buttonSearch.setImageResource(R.drawable.ic_search_off); + } + } _buttonShare = findViewById(R.id.button_share); val onShare = _onShare; @@ -171,9 +199,22 @@ abstract class VideoListEditorView : LinearLayout { .load(R.drawable.placeholder_video_thumbnail) .into(_imagePlaylistThumbnail) } - + _loadedVideos = videos; + _loadedVideosCanEdit = canEdit; _videoListEditorView.setVideos(videos, canEdit); } + fun filterVideos(videos: List): List { + var toReturn = videos; + val searchStr = _search.textSearch.text + if(!searchStr.isNullOrBlank()) + toReturn = toReturn.filter { it.name.contains(searchStr, true) || it.author.name.contains(searchStr, true) }; + return toReturn; + } + + fun updateVideoFilters() { + val videos = _loadedVideos ?: return; + _videoListEditorView.setVideos(filterVideos(videos), _loadedVideosCanEdit); + } protected fun setButtonDownloadVisible(isVisible: Boolean) { _buttonDownload.visibility = if (isVisible) View.VISIBLE else View.GONE; 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 522647de..cad49efd 100644 --- a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt +++ b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt @@ -214,5 +214,38 @@ class VideoHelper { } else return 0; } + + fun mediaExtensionToMimetype(extension: String): String? { + return videoExtensionToMimetype(extension) ?: audioExtensionToMimetype(extension); + } + fun videoExtensionToMimetype(extension: String): String? { + val extensionTrimmed = extension.trim('.').lowercase(); + return when (extensionTrimmed) { + "mp4" -> return "video/mp4"; + "webm" -> return "video/webm"; + "m3u8" -> return "video/x-mpegURL"; + "3gp" -> return "video/3gpp"; + "mov" -> return "video/quicktime"; + "mkv" -> return "video/x-matroska"; + "mp4a" -> return "audio/vnd.apple.mpegurl"; + "mpga" -> return "audio/mpga"; + "mp3" -> return "audio/mp3"; + "webm" -> return "audio/webm"; + "3gp" -> return "audio/3gpp"; + else -> null; + } + } + fun audioExtensionToMimetype(extension: String): String? { + val extensionTrimmed = extension.trim('.').lowercase(); + return when (extensionTrimmed) { + "mkv" -> return "audio/x-matroska"; + "mp4a" -> return "audio/vnd.apple.mpegurl"; + "mpga" -> return "audio/mpga"; + "mp3" -> return "audio/mp3"; + "webm" -> return "audio/webm"; + "3gp" -> return "audio/3gpp"; + else -> null; + } + } } } diff --git a/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt b/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt index 00594df7..97fe6408 100644 --- a/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt +++ b/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt @@ -8,6 +8,7 @@ import com.bumptech.glide.Glide import com.futo.platformplayer.PresetImages import com.futo.platformplayer.R import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateSubscriptions import kotlinx.serialization.Contextual import kotlinx.serialization.Transient import java.io.File @@ -35,6 +36,12 @@ data class ImageVariable( } else if(!url.isNullOrEmpty()) { Glide.with(imageView) .load(url) + .error(if(!subscriptionUrl.isNullOrBlank()) StateSubscriptions.instance.getSubscription(subscriptionUrl!!)?.channel?.thumbnail else null) + .placeholder(R.drawable.placeholder_channel_thumbnail) + .into(imageView); + } else if(!subscriptionUrl.isNullOrEmpty()) { + Glide.with(imageView) + .load(StateSubscriptions.instance.getSubscription(subscriptionUrl!!)?.channel?.thumbnail) .placeholder(R.drawable.placeholder_channel_thumbnail) .into(imageView); } else if(!presetName.isNullOrEmpty()) { diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt index 15235017..123b0320 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -30,6 +30,7 @@ import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.subsexchange.ChannelRequest import com.futo.platformplayer.subsexchange.ChannelResolve +import com.futo.platformplayer.subsexchange.ExchangeContract import kotlinx.coroutines.CoroutineScope import java.time.OffsetDateTime import java.util.concurrent.ExecutionException @@ -77,22 +78,31 @@ abstract class SubscriptionsTaskFetchAlgorithm( val exs: ArrayList = arrayListOf(); - - - val contractableTasks = tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) }; - val contract = if(contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map { ChannelRequest(it.url) }.toTypedArray()) else null; - if(contract?.provided?.isNotEmpty() == true) - Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}"); + var contract: ExchangeContract? = null; var providedTasks: MutableList? = null; - if(contract != null && contract.required.isNotEmpty()){ - providedTasks = mutableListOf() - for(task in tasks.toList()){ - if(!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) { - providedTasks.add(task); - tasks.remove(task); + + try { + val contractableTasks = + tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) }; + contract = + if (contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map { + ChannelRequest(it.url) + }.toTypedArray()) else null; + if (contract?.provided?.isNotEmpty() == true) + Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}"); + if (contract != null && contract.required.isNotEmpty()) { + providedTasks = mutableListOf() + for (task in tasks.toList()) { + if (!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) { + providedTasks.add(task); + tasks.remove(task); + } } } } + catch(ex: Throwable){ + Logger.e("SubscriptionsTaskFetchAlgorithm", "Failed to retrieve SubsExchange contract due to: " + ex.message, ex); + } val failedPlugins = mutableListOf(); val cachedChannels = mutableListOf() diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/ImageVariableOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/ImageVariableOverlay.kt index 5d5a6a50..b8fb8a77 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/ImageVariableOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/ImageVariableOverlay.kt @@ -98,7 +98,11 @@ class ImageVariableOverlay: ConstraintLayout { UIDialogs.toast(context, "No thumbnail found"); return@subscribe; } - _selected = ImageVariable(it.channel.thumbnail); + val channelUrl = it.channel.url; + _selected = ImageVariable(it.channel.thumbnail).let { + it.subscriptionUrl = channelUrl; + return@let it; + } updateSelected(); }; }; diff --git a/app/src/main/res/drawable/ic_search_off.xml b/app/src/main/res/drawable/ic_search_off.xml new file mode 100644 index 00000000..08810c6c --- /dev/null +++ b/app/src/main/res/drawable/ic_search_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/fragment_playlists.xml b/app/src/main/res/layout/fragment_playlists.xml index cd2050b6..b8ca23df 100644 --- a/app/src/main/res/layout/fragment_playlists.xml +++ b/app/src/main/res/layout/fragment_playlists.xml @@ -13,7 +13,7 @@ @@ -87,7 +87,7 @@ - - + + android:gravity="center_vertical"> - + + + + + + + + + + + + + + + + + + - @@ -136,7 +183,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior" - app:layout_constraintTop_toBottomOf="@id/text_view_all" + app:layout_constraintTop_toBottomOf="@id/playlists_filter_container" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" android:paddingTop="10dp" diff --git a/app/src/main/res/layout/fragment_video_list_editor.xml b/app/src/main/res/layout/fragment_video_list_editor.xml index a906421b..f86b69e4 100644 --- a/app/src/main/res/layout/fragment_video_list_editor.xml +++ b/app/src/main/res/layout/fragment_video_list_editor.xml @@ -30,7 +30,7 @@ android:orientation="vertical"> + android:layout_height="wrap_content"> + @@ -116,6 +132,8 @@ app:layout_constraintLeft_toLeftOf="@id/container_buttons" app:layout_constraintBottom_toTopOf="@id/container_buttons" /> + + + app:srcCompat="@drawable/ic_search" + app:tint="@color/white" + android:padding="5dp" + android:scaleType="fitCenter" /> + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 256fe1ef..9d82ff85 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -973,6 +973,16 @@ Release Date (Oldest) Release Date (Newest) + + Name (Ascending) + Name (Descending) + Modified Date (Oldest) + Modified Date (Newest) + Creation Date (Oldest) + Creation Date (Newest) + Play Date (Oldest) + Play Date (Newest) + Preview List From 0a59e04f199946a67edcef6a945999cd495957ea Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 2 Apr 2025 23:40:37 +0200 Subject: [PATCH 13/19] Fix ui offset issue when opening video through search url --- .../mainactivity/main/ContentSearchResultsFragment.kt | 11 +++++++++-- .../fragment/mainactivity/main/SuggestionsFragment.kt | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) 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 a078eb0c..b8b0b567 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 @@ -9,6 +9,7 @@ import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UISlideOverlays +import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.structures.IPager @@ -160,8 +161,14 @@ class ContentSearchResultsFragment : MainFragment() { navigate(it); else if(StatePlatform.instance.hasEnabledChannelClient(it)) navigate(it); - else - navigate(it); + else { + val url = it; + activity?.let { + close() + if(it is MainActivity) + it.navigate(it.getFragment(), url); + } + } } else setQuery(it, true); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt index 95055d0a..a07de94e 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.* +import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.logging.Logger @@ -122,8 +123,14 @@ class SuggestionsFragment : MainFragment { navigate(it); else if(StatePlatform.instance.hasEnabledChannelClient(it)) navigate(it); - else - navigate(it); + else { + val url = it; + activity?.let { + close() + if(it is MainActivity) + it.navigate(it.getFragment(), url); + } + } } else navigate(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl)); From 7d64003d1c97669dceb885847fd11ea27c78d56b Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Fri, 4 Apr 2025 00:37:26 +0200 Subject: [PATCH 14/19] Feed filter loading improved, home filters support, various peripheral stuff --- .../java/com/futo/platformplayer/Settings.kt | 5 +- .../futo/platformplayer/UISlideOverlays.kt | 27 ++-- .../media/models/contents/IPlatformContent.kt | 1 + .../models/video/SerializedPlatformVideo.kt | 2 + .../api/media/structures/IRefreshPager.kt | 2 +- .../api/media/structures/ReusablePager.kt | 122 +++++++++++++++++- .../fragment/mainactivity/main/FeedView.kt | 57 ++++++-- .../mainactivity/main/HomeFragment.kt | 114 +++++++++++++--- .../serializers/PlatformContentSerializer.kt | 4 +- .../states/StateSubscriptions.kt | 2 +- .../stores/StringArrayStorage.kt | 15 +++ .../SubscriptionsTaskFetchAlgorithm.kt | 1 + .../subsexchange/ChannelResult.kt | 9 +- .../subsexchange/SubsExchangeClient.kt | 1 + .../futo/platformplayer/views/ToggleBar.kt | 30 ++++- .../views/others/ToggleTagView.kt | 35 ++++- .../overlays/slideup/SlideUpMenuOverlay.kt | 7 + app/src/main/res/layout/view_toggle_bar.xml | 6 +- app/src/main/res/layout/view_toggle_tag.xml | 31 +++-- app/src/main/res/values/strings.xml | 2 + 20 files changed, 411 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 989997c3..f68a60f9 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -205,7 +205,7 @@ class Settings : FragmentedStorageFileJson() { var home = HomeSettings(); @Serializable class HomeSettings { - @FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 5) + @FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 3) @DropdownFieldOptionsId(R.array.feed_style) var homeFeedStyle: Int = 1; @@ -216,6 +216,9 @@ class Settings : FragmentedStorageFileJson() { return FeedStyle.THUMBNAIL; } + @FormField(R.string.show_home_filters, FieldForm.TOGGLE, R.string.show_home_filters_description, 4) + var showHomeFilters: Boolean = true; + @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6) var previewFeedItems: Boolean = true; diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index a1aa71b7..874ffd4f 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -1148,7 +1148,7 @@ class UISlideOverlays { container.context.getString(R.string.decide_which_buttons_should_be_pinned), tag = "", call = { - showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) { + showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }, { val selected = it .map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } } .filter { it != null } @@ -1156,7 +1156,7 @@ class UISlideOverlays { .toList(); onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) }); - } + }); }, invokeParent = false )) @@ -1164,29 +1164,40 @@ class UISlideOverlays { return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.more_options), null, true, *views).apply { show() }; } - - fun showOrderOverlay(container: ViewGroup, title: String, options: List>, onOrdered: (List)->Unit) { + fun showOrderOverlay(container: ViewGroup, title: String, options: List>, onOrdered: (List)->Unit, description: String? = null) { val selection: MutableList = mutableListOf(); var overlay: SlideUpMenuOverlay? = null; overlay = SlideUpMenuOverlay(container.context, container, title, container.context.getString(R.string.save), true, - options.map { SlideUpMenuItem( + listOf( + if(!description.isNullOrEmpty()) SlideUpMenuGroup(container.context, "", description, "", listOf()) else null, + ).filterNotNull() + + (options.map { SlideUpMenuItem( container.context, R.drawable.ic_move_up, it.first, "", tag = it.second, call = { + val overlayItem = overlay?.getSlideUpItemByTag(it.second); if(overlay!!.selectOption(null, it.second, true, true)) { - if(!selection.contains(it.second)) + if(!selection.contains(it.second)) { selection.add(it.second); - } else + if(overlayItem != null) { + overlayItem.setSubText(selection.indexOf(it.second).toString()); + } + } + } else { selection.remove(it.second); + if(overlayItem != null) { + overlayItem.setSubText(""); + } + } }, invokeParent = false ) - }); + })); overlay.onOK.subscribe { onOrdered.invoke(selection); overlay.hide(); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt index edb1caa3..a823316a 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt @@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.models.contents import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.models.PlatformAuthorLink import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames import java.time.OffsetDateTime interface IPlatformContent { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt index 68bb5cb9..c9e02d92 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt @@ -10,6 +10,7 @@ import com.futo.polycentric.core.combineHashCodes import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNames import java.time.OffsetDateTime @kotlinx.serialization.Serializable @@ -20,6 +21,7 @@ open class SerializedPlatformVideo( override val thumbnails: Thumbnails, override val author: PlatformAuthorLink, @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) + @JsonNames("datetime", "dateTime") override val datetime: OffsetDateTime? = null, override val url: String, override val shareUrl: String = "", diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/IRefreshPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/IRefreshPager.kt index 34a9e41a..375f9343 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/structures/IRefreshPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/IRefreshPager.kt @@ -6,7 +6,7 @@ import com.futo.platformplayer.constructs.Event1 * A RefreshPager represents a pager that can be modified overtime (eg. By getting more results later, by recreating the pager) * When the onPagerChanged event is emitted, a new pager instance is passed, or requested via getCurrentPager */ -interface IRefreshPager { +interface IRefreshPager: IPager { val onPagerChanged: Event1>; val onPagerError: Event1; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/ReusablePager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/ReusablePager.kt index 45f6aea5..ee1b39f2 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/structures/ReusablePager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/ReusablePager.kt @@ -1,5 +1,7 @@ package com.futo.platformplayer.api.media.structures +import com.futo.platformplayer.api.media.structures.ReusablePager.Window +import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.logging.Logger /** @@ -9,8 +11,8 @@ import com.futo.platformplayer.logging.Logger * A "Window" is effectively a pager that just reads previous results from the shared results, but when the end is reached, it will call nextPage on the parent if possible for new results. * This allows multiple Windows to exist of the same pager, without messing with position, or duplicate requests */ -class ReusablePager: INestedPager, IPager { - private val _pager: IPager; +open class ReusablePager: INestedPager, IReusablePager { + protected var _pager: IPager; val previousResults = arrayListOf(); constructor(subPager: IPager) { @@ -44,7 +46,7 @@ class ReusablePager: INestedPager, IPager { return previousResults; } - fun getWindow(): Window { + override fun getWindow(): Window { return Window(this); } @@ -95,4 +97,118 @@ class ReusablePager: INestedPager, IPager { return ReusablePager(this); } } +} + + +public class ReusableRefreshPager: INestedPager, IReusablePager { + protected var _pager: IRefreshPager; + val previousResults = arrayListOf(); + + private var _currentPage: IPager; + + + val onPagerChanged = Event1>() + val onPagerError = Event1() + + constructor(subPager: IRefreshPager) { + this._pager = subPager; + _currentPage = this; + synchronized(previousResults) { + previousResults.addAll(subPager.getResults()); + } + _pager.onPagerError.subscribe(onPagerError::emit); + _pager.onPagerChanged.subscribe { + _currentPage = it; + synchronized(previousResults) { + previousResults.clear(); + previousResults.addAll(it.getResults()); + } + + onPagerChanged.emit(_currentPage); + }; + } + + override fun findPager(query: (IPager) -> Boolean): IPager? { + if(query(_pager)) + return _pager; + else if(_pager is INestedPager<*>) + return (_pager as INestedPager).findPager(query); + return null; + } + + override fun hasMorePages(): Boolean { + return _pager.hasMorePages(); + } + + override fun nextPage() { + _pager.nextPage(); + } + + override fun getResults(): List { + val results = _pager.getResults(); + synchronized(previousResults) { + previousResults.addAll(results); + } + return previousResults; + } + + override fun getWindow(): RefreshWindow { + return RefreshWindow(this); + } + + + class RefreshWindow: IPager, INestedPager, IRefreshPager { + private val _parent: ReusableRefreshPager; + private var _position: Int = 0; + private var _read: Int = 0; + + private var _currentResults: List; + + override val onPagerChanged = Event1>(); + override val onPagerError = Event1(); + + + override fun getCurrentPager(): IPager { + return _parent.getWindow(); + } + + constructor(parent: ReusableRefreshPager) { + _parent = parent; + + synchronized(_parent.previousResults) { + _currentResults = _parent.previousResults.toList(); + _read += _currentResults.size; + } + parent.onPagerChanged.subscribe(onPagerChanged::emit); + parent.onPagerError.subscribe(onPagerError::emit); + } + + + override fun hasMorePages(): Boolean { + return _parent.previousResults.size > _read || _parent.hasMorePages(); + } + + override fun nextPage() { + synchronized(_parent.previousResults) { + if (_parent.previousResults.size <= _read) { + _parent.nextPage(); + _parent.getResults(); + } + _currentResults = _parent.previousResults.drop(_read).toList(); + _read += _currentResults.size; + } + } + + override fun getResults(): List { + return _currentResults; + } + + override fun findPager(query: (IPager) -> Boolean): IPager? { + return _parent.findPager(query); + } + } +} + +interface IReusablePager: IPager { + fun getWindow(): IPager; } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt index af033c51..58868ee4 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt @@ -3,12 +3,15 @@ package com.futo.platformplayer.fragment.mainactivity.main import android.content.Context import android.content.res.Configuration import android.graphics.Color +import android.util.DisplayMetrics +import android.view.Display import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.* import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.LayoutManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout @@ -20,6 +23,7 @@ import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.others.TagsView @@ -28,7 +32,9 @@ import com.futo.platformplayer.views.adapters.InsertedViewHolder import com.futo.platformplayer.views.announcements.AnnouncementView import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.time.OffsetDateTime import kotlin.math.max @@ -68,6 +74,7 @@ abstract class FeedView : L private val _scrollListener: RecyclerView.OnScrollListener; private var _automaticNextPageCounter = 0; + private val _automaticBackoff = arrayOf(0, 500, 1000, 1000, 2000, 5000, 5000, 5000); constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, GridLayoutManager, TPager, TResult, TConverted, InsertedViewHolder>? = null) : super(inflater.context) { this.fragment = fragment; @@ -129,6 +136,7 @@ abstract class FeedView : L _toolbarContentView = findViewById(R.id.container_toolbar_content); _nextPageHandler = TaskHandler>({fragment.lifecycleScope}, { + if (it is IAsyncPager<*>) it.nextPageAsync(); else @@ -182,26 +190,53 @@ abstract class FeedView : L private fun ensureEnoughContentVisible(filteredResults: List) { val canScroll = if (recyclerData.results.isEmpty()) false else { + val height = resources.displayMetrics.heightPixels; + val layoutManager = recyclerData.layoutManager val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() - - if (firstVisibleItemPosition != RecyclerView.NO_POSITION) { - val firstVisibleView = layoutManager.findViewByPosition(firstVisibleItemPosition) - val itemHeight = firstVisibleView?.height ?: 0 - val occupiedSpace = recyclerData.results.size / recyclerData.layoutManager.spanCount * itemHeight - val recyclerViewHeight = _recyclerResults.height - Logger.i(TAG, "ensureEnoughContentVisible loadNextPage occupiedSpace=$occupiedSpace recyclerViewHeight=$recyclerViewHeight") - occupiedSpace >= recyclerViewHeight + val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null; + val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition(); + val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null; + if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) { + false; + } + else if (firstVisibleItemView != null && height != null && firstVisibleItemView.height * recyclerData.results.size < height) { + false; } else { - false + true; } } + Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter") if (!canScroll || filteredResults.isEmpty()) { _automaticNextPageCounter++ - if(_automaticNextPageCounter <= 4) - loadNextPage() + if(_automaticNextPageCounter < _automaticBackoff.size) { + if(_automaticNextPageCounter > 0) { + val automaticNextPageCounterSaved = _automaticNextPageCounter; + fragment.lifecycleScope.launch(Dispatchers.Default) { + val backoff = _automaticBackoff[Math.min(_automaticBackoff.size - 1, _automaticNextPageCounter)]; + + withContext(Dispatchers.Main) { + setLoading(true); + } + delay(backoff.toLong()); + if(automaticNextPageCounterSaved == _automaticNextPageCounter) { + withContext(Dispatchers.Main) { + loadNextPage(); + } + } + else { + withContext(Dispatchers.Main) { + setLoading(false); + } + } + } + } + else + loadNextPage(); + } } else { + Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset"); _automaticNextPageCounter = 0; } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt index 9cdac8f9..d5e01461 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt @@ -5,22 +5,32 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.allViews +import androidx.core.view.contains import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import com.futo.platformplayer.* +import com.futo.platformplayer.UISlideOverlays.Companion.showOrderOverlay import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.api.media.structures.IRefreshPager +import com.futo.platformplayer.api.media.structures.IReusablePager +import com.futo.platformplayer.api.media.structures.ReusablePager +import com.futo.platformplayer.api.media.structures.ReusableRefreshPager import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptExecutionException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.NoResultsView import com.futo.platformplayer.views.ToggleBar @@ -91,6 +101,7 @@ class HomeFragment : MainFragment() { _view?.setPreviewsEnabled(previewsEnabled && Settings.instance.home.previewFeedItems); } + @SuppressLint("ViewConstructor") class HomeView : ContentFeedView { override val feedStyle: FeedStyle get() = Settings.instance.home.getHomeFeedStyle(); @@ -100,11 +111,20 @@ class HomeFragment : MainFragment() { private val _taskGetPager: TaskHandler>; override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar + private var _lastPager: IReusablePager? = null; + constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, GridLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null) : super(fragment, inflater, cachedRecyclerData) { _taskGetPager = TaskHandler>({ fragment.lifecycleScope }, { StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope) }) - .success { loadedResult(it); } + .success { + val wrappedPager = if(it is IRefreshPager) + ReusableRefreshPager(it); + else + ReusablePager(it); + _lastPager = wrappedPager; + loadedResult(wrappedPager.getWindow()); + } .exception { } .exception { Logger.w(ChannelFragment.TAG, "Plugin failure.", it); @@ -208,21 +228,81 @@ class HomeFragment : MainFragment() { private val _filterLock = Object(); private var _toggleRecent = false; + private var _toggleWatched = false; + private var _togglePluginsDisabled = mutableListOf(); + private var _togglesConfig = FragmentedStorage.get("home_toggles"); fun initializeToolbarContent() { - //Not stable enough with current viewport paging, doesn't work with less results, and reloads content instead of just re-filtering existing - /* - _toggleBar = ToggleBar(context).apply { - layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - } - synchronized(_filterLock) { - _toggleBar?.setToggles( - //TODO: loadResults needs to be replaced with an internal reload of the current content - ToggleBar.Toggle("Recent", _toggleRecent) { _toggleRecent = it; loadResults(false) } - ) - } + if(_toolbarContentView.allViews.any { it is ToggleBar }) + _toolbarContentView.removeView(_toolbarContentView.allViews.find { it is ToggleBar }); - _toolbarContentView.addView(_toggleBar, 0); - */ + if(Settings.instance.home.showHomeFilters) { + + if (!_togglesConfig.any()) { + _togglesConfig.set("today", "watched", "plugins"); + _togglesConfig.save(); + } + _toggleBar = ToggleBar(context).apply { + layoutParams = + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + } + _togglePluginsDisabled.clear(); + synchronized(_filterLock) { + val buttonsPlugins = (if (_togglesConfig.contains("plugins")) + (StatePlatform.instance.getEnabledClients() + .map { plugin -> + ToggleBar.Toggle(plugin.name, plugin.icon, true, { + if (it) { + if (_togglePluginsDisabled.contains(plugin.id)) + _togglePluginsDisabled.remove(plugin.id); + } else { + if (!_togglePluginsDisabled.contains(plugin.id)) + _togglePluginsDisabled.add(plugin.id); + } + reloadForFilters(); + }).withTag("plugins") + }) + else listOf()) + val buttons = (listOf( + (if (_togglesConfig.contains("today")) + ToggleBar.Toggle("Today", _toggleRecent) { + _toggleRecent = it; reloadForFilters() + } + .withTag("today") else null), + (if (_togglesConfig.contains("watched")) + ToggleBar.Toggle("Unwatched", _toggleWatched) { + _toggleWatched = it; reloadForFilters() + } + .withTag("watched") else null), + ).filterNotNull() + buttonsPlugins) + .sortedBy { _togglesConfig.indexOf(it.tag ?: "") } ?: listOf() + + val buttonSettings = ToggleBar.Toggle("", R.drawable.ic_settings, true, { + showOrderOverlay(_overlayContainer, + "Visible home filters", + listOf( + Pair("Plugins", "plugins"), + Pair("Today", "today"), + Pair("Watched", "watched") + ), + { + val newArray = it.map { it.toString() }.toTypedArray(); + _togglesConfig.set(*(if (newArray.any()) newArray else arrayOf("none"))); + _togglesConfig.save(); + initializeToolbarContent(); + }, + "Select which toggles you want to see in order. You can also choose to hide filters in the Grayjay Settings" + ); + }).asButton(); + + val buttonsOrder = (buttons + listOf(buttonSettings)).toTypedArray(); + _toggleBar?.setToggles(*buttonsOrder); + } + + _toolbarContentView.addView(_toggleBar, 0); + } + } + fun reloadForFilters() { + _lastPager?.let { loadedResult(it.getWindow()) }; } override fun filterResults(results: List): List { @@ -232,7 +312,11 @@ class HomeFragment : MainFragment() { if(StateMeta.instance.isCreatorHidden(it.author.url)) return@filter false; - if(_toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 23) { + if(_toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25) + return@filter false; + if(_toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0)) + return@filter false; + if(_togglePluginsDisabled.any() && it.id.pluginId != null && _togglePluginsDisabled.contains(it.id.pluginId)) { return@filter false; } 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 02a39160..db540ea1 100644 --- a/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt +++ b/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt @@ -19,10 +19,10 @@ import kotlinx.serialization.json.jsonPrimitive class PlatformContentSerializer : JsonContentPolymorphicSerializer(SerializedPlatformContent::class) { override fun selectDeserializer(element: JsonElement): DeserializationStrategy { - val obj = element.jsonObject["contentType"]; + val obj = element.jsonObject["contentType"] ?: element.jsonObject["ContentType"]; //TODO: Remove this temporary fallback..at some point - if(obj == null && element.jsonObject["isLive"]?.jsonPrimitive?.booleanOrNull != null) + if(obj == null && (element.jsonObject["isLive"]?.jsonPrimitive?.booleanOrNull ?: element.jsonObject["IsLive"]?.jsonPrimitive?.booleanOrNull) != null) return SerializedPlatformVideo.serializer(); if(obj?.jsonPrimitive?.isString != false) { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt index 1d1acff6..c92c80b0 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -69,7 +69,7 @@ class StateSubscriptions { val onSubscriptionsChanged = Event2, Boolean>(); - private val _subsExchangeServer = "https://exchange.grayjay.app/"; + private val _subsExchangeServer = "http://10.10.15.159"//"https://exchange.grayjay.app/"; private val _subscriptionKey = FragmentedStorage.get("sub_exchange_key"); init { diff --git a/app/src/main/java/com/futo/platformplayer/stores/StringArrayStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/StringArrayStorage.kt index be1e69e3..0d072bba 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/StringArrayStorage.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/StringArrayStorage.kt @@ -41,4 +41,19 @@ class StringArrayStorage : FragmentedStorageFileJson() { return values.toList(); } } + fun any(): Boolean { + synchronized(values) { + return values.any(); + } + } + fun contains(v: String): Boolean { + synchronized(values) { + return values.contains(v); + } + } + fun indexOf(v: String): Int { + synchronized(values){ + return values.indexOf(v); + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt index 123b0320..ce0e19c2 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -153,6 +153,7 @@ abstract class SubscriptionsTaskFetchAlgorithm( *resolves ); if (resolve != null) { + val invalids = resolve.filter { it.content.any { it.datetime == null } }; UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size})") for(result in resolve){ val task = providedTasks?.find { it.url == result.channelUrl }; diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt index c13f101c..957d415d 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt @@ -5,6 +5,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import com.futo.platformplayer.serializers.OffsetDateTimeStringSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.time.OffsetDateTime @@ -12,12 +13,12 @@ import java.time.OffsetDateTime @Serializable class ChannelResult( @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) - @SerialName("DateTime") + @SerialName("dateTime") var dateTime: OffsetDateTime, - @SerialName("ChannelUrl") + @SerialName("channelUrl") var channelUrl: String, - @SerialName("Content") + @SerialName("content") var content: List, - @SerialName("Channel") + @SerialName("channel") var channel: IPlatformChannel? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt index a58e17b0..0a55516f 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt @@ -52,6 +52,7 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { val contractResolve = convertResolves(*resolves) val result = post("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json") + Logger.v("SubsExchangeClient", "Resolve:" + result); return Serializer.json.decodeFromString(result) } suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { diff --git a/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt b/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt index be3d8df8..ef2eadbc 100644 --- a/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt +++ b/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt @@ -12,6 +12,7 @@ import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.media.models.channels.SerializedChannel import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.models.ImageVariable import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.states.StateSubscriptionGroups @@ -46,7 +47,12 @@ class ToggleBar : LinearLayout { _tagsContainer.removeAllViews(); for(button in buttons) { _tagsContainer.addView(ToggleTagView(context).apply { - this.setInfo(button.name, button.isActive); + if(button.icon > 0) + this.setInfo(button.icon, button.name, button.isActive, button.isButton); + else if(button.iconVariable != null) + this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton); + else + this.setInfo(button.name, button.isActive, button.isButton); this.onClick.subscribe { button.action(it); }; }); } @@ -55,20 +61,42 @@ class ToggleBar : LinearLayout { class Toggle { val name: String; val icon: Int; + val iconVariable: ImageVariable?; val action: (Boolean)->Unit; val isActive: Boolean; + var isButton: Boolean = false + private set; + var tag: String? = null; + constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (Boolean)->Unit) { + this.name = name; + this.icon = 0; + this.iconVariable = icon; + this.action = action; + this.isActive = isActive; + } constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) { this.name = name; this.icon = icon; + this.iconVariable = null; this.action = action; this.isActive = isActive; } constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) { this.name = name; this.icon = 0; + this.iconVariable = null; this.action = action; this.isActive = isActive; } + + fun asButton(): Toggle{ + isButton = true; + return this; + } + fun withTag(str: String): Toggle { + tag = str; + return this; + } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt index 27c4e68d..65b13eb0 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt @@ -4,19 +4,27 @@ import android.content.Context import android.graphics.Color import android.util.AttributeSet import android.view.LayoutInflater +import android.view.View import android.widget.FrameLayout +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.constructs.Event1 +import com.futo.platformplayer.images.GlideHelper +import com.futo.platformplayer.models.ImageVariable class ToggleTagView : LinearLayout { private val _root: FrameLayout; private val _textTag: TextView; private var _text: String = ""; + private var _image: ImageView; var isActive: Boolean = false private set; + var isButton: Boolean = false + private set; var onClick = Event1(); @@ -24,7 +32,12 @@ class ToggleTagView : LinearLayout { LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true); _root = findViewById(R.id.root); _textTag = findViewById(R.id.text_tag); - _root.setOnClickListener { setToggle(!isActive); onClick.emit(isActive); } + _image = findViewById(R.id.image_tag); + _root.setOnClickListener { + if(!isButton) + setToggle(!isActive); + onClick.emit(isActive); + } } fun setToggle(isActive: Boolean) { @@ -39,9 +52,27 @@ class ToggleTagView : LinearLayout { } } - fun setInfo(text: String, isActive: Boolean) { + fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false) { _text = text; _textTag.text = text; setToggle(isActive); + _image.setImageResource(imageResource); + _image.visibility = View.VISIBLE; + this.isButton = isButton; + } + fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false) { + _text = text; + _textTag.text = text; + setToggle(isActive); + image.setImageView(_image, R.drawable.ic_error_pred); + _image.visibility = View.VISIBLE; + this.isButton = isButton; + } + fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false) { + _image.visibility = View.GONE; + _text = text; + _textTag.text = text; + setToggle(isActive); + this.isButton = isButton; } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt index 89cf1359..58850998 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt @@ -113,6 +113,13 @@ class SlideUpMenuOverlay : RelativeLayout { _textOK.visibility = View.VISIBLE; } } + fun getSlideUpItemByTag(itemTag: Any?): SlideUpMenuItem? { + for(view in groupItems){ + if(view is SlideUpMenuItem && view.itemTag == itemTag) + return view; + } + return null; + } fun selectOption(groupTag: Any?, itemTag: Any?, multiSelect: Boolean = false, toggle: Boolean = false): Boolean { var didSelect = false; diff --git a/app/src/main/res/layout/view_toggle_bar.xml b/app/src/main/res/layout/view_toggle_bar.xml index 3da2f363..7b99d09f 100644 --- a/app/src/main/res/layout/view_toggle_bar.xml +++ b/app/src/main/res/layout/view_toggle_bar.xml @@ -3,14 +3,14 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> - - + \ No newline at end of file diff --git a/app/src/main/res/layout/view_toggle_tag.xml b/app/src/main/res/layout/view_toggle_tag.xml index 886f2de5..eca7010c 100644 --- a/app/src/main/res/layout/view_toggle_tag.xml +++ b/app/src/main/res/layout/view_toggle_tag.xml @@ -10,16 +10,27 @@ android:layout_marginTop="17dp" android:layout_marginBottom="8dp" android:id="@+id/root"> - - + android:layout_height="match_parent" + android:orientation="horizontal"> + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9d82ff85..85549f27 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -417,6 +417,8 @@ If subscription groups should be shown above your subscriptions to filter Preview Feed Items When the preview feedstyle is used, if items should auto-preview when scrolling over them + Show Home Filters + If the home filters should be shown above home Log Level Logging Sync Grayjay From b14518edb1e0caade4917d18ad79432c2ff5f2f4 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Sat, 5 Apr 2025 01:02:50 +0200 Subject: [PATCH 15/19] Home filter fixes, persistent sorts, subs exchange fixes, playlist video options --- .../java/com/futo/platformplayer/Settings.kt | 2 + .../mainactivity/main/DownloadsFragment.kt | 28 +++-- .../fragment/mainactivity/main/FeedView.kt | 3 + .../mainactivity/main/HomeFragment.kt | 52 ++++---- .../mainactivity/main/PlaylistFragment.kt | 4 + .../mainactivity/main/PlaylistsFragment.kt | 29 +++-- .../mainactivity/main/VideoDetailView.kt | 3 + .../mainactivity/main/VideoListEditorView.kt | 2 + .../mainactivity/main/WatchLaterFragment.kt | 3 + .../states/StateSubscriptions.kt | 2 +- .../SubscriptionsTaskFetchAlgorithm.kt | 118 +++++++++++------- .../subsexchange/SubsExchangeClient.kt | 8 +- .../views/adapters/VideoListEditorAdapter.kt | 2 + .../adapters/VideoListEditorViewHolder.kt | 7 ++ .../views/lists/VideoListEditorView.kt | 5 + .../views/others/ToggleTagView.kt | 3 + .../views/overlays/QueueEditorOverlay.kt | 6 + app/src/main/res/layout/list_playlist.xml | 42 +++++-- app/src/main/res/layout/view_toggle_tag.xml | 11 +- app/src/main/res/values/strings.xml | 2 + 20 files changed, 218 insertions(+), 114 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index f68a60f9..cedc4316 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -218,6 +218,8 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.show_home_filters, FieldForm.TOGGLE, R.string.show_home_filters_description, 4) var showHomeFilters: Boolean = true; + @FormField(R.string.show_home_filters_plugin_names, FieldForm.TOGGLE, R.string.show_home_filters_plugin_names_description, 5) + var showHomeFiltersPluginNames: Boolean = false; @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6) var previewFeedItems: Boolean = true; 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 d402a6e2..217165ae 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 @@ -21,6 +21,8 @@ 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.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.toHumanBytesSize import com.futo.platformplayer.toHumanDuration import com.futo.platformplayer.views.AnyInsertedAdapterView @@ -103,12 +105,15 @@ class DownloadsFragment : MainFragment() { private val _listDownloaded: AnyInsertedAdapterView; private var lastDownloads: List? = null; - private var ordering: String? = "nameAsc"; + private var ordering = FragmentedStorage.get("downloads_ordering") constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) { inflater.inflate(R.layout.fragment_downloads, this); _frag = frag; + if(ordering.value.isNullOrBlank()) + ordering.value = "nameAsc"; + _usageUsed = findViewById(R.id.downloads_usage_used); _usageAvailable = findViewById(R.id.downloads_usage_available); _usageProgress = findViewById(R.id.downloads_usage_progress); @@ -132,22 +137,23 @@ class DownloadsFragment : MainFragment() { spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also { it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); }; - spinnerSortBy.setSelection(0); + val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc"); spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { when(pos) { - 0 -> ordering = "nameAsc" - 1 -> ordering = "nameDesc" - 2 -> ordering = "downloadDateAsc" - 3 -> ordering = "downloadDateDesc" - 4 -> ordering = "releasedAsc" - 5 -> ordering = "releasedDesc" - else -> ordering = null + 0 -> ordering.setAndSave("nameAsc") + 1 -> ordering.setAndSave("nameDesc") + 2 -> ordering.setAndSave("downloadDateAsc") + 3 -> ordering.setAndSave("downloadDateDesc") + 4 -> ordering.setAndSave("releasedAsc") + 5 -> ordering.setAndSave("releasedDesc") + else -> ordering.setAndSave("") } updateContentFilters() } override fun onNothingSelected(parent: AdapterView<*>?) = Unit }; + spinnerSortBy.setSelection(Math.max(0, options.indexOf(ordering.value))); _listDownloaded = findViewById(R.id.list_downloaded) .asAnyWithTop(findViewById(R.id.downloads_top)) { @@ -230,8 +236,8 @@ class DownloadsFragment : MainFragment() { var vidsToReturn = vids; if(!_listDownloadSearch.text.isNullOrEmpty()) vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) || it.author.name.contains(_listDownloadSearch.text, true) }; - if(!ordering.isNullOrEmpty()) { - vidsToReturn = when(ordering){ + if(!ordering.value.isNullOrEmpty()) { + vidsToReturn = when(ordering.value){ "downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX }; "downloadDateDesc" -> vidsToReturn.sortedByDescending { it.downloadDate ?: OffsetDateTime.MIN }; "nameAsc" -> vidsToReturn.sortedBy { it.name.lowercase() } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt index 58868ee4..17ca5510 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt @@ -240,6 +240,9 @@ abstract class FeedView : L _automaticNextPageCounter = 0; } } + fun resetAutomaticNextPageCounter(){ + _automaticNextPageCounter = 0; + } protected fun setTextCentered(text: String?) { _textCentered.text = text; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt index d5e01461..a450a2ed 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt @@ -6,7 +6,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.allViews -import androidx.core.view.contains import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import com.futo.platformplayer.* @@ -29,6 +28,7 @@ import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.views.FeedStyle @@ -37,7 +37,6 @@ import com.futo.platformplayer.views.ToggleBar 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.announcements.AnnouncementView import com.futo.platformplayer.views.buttons.BigButton import kotlinx.coroutines.runBlocking import java.time.OffsetDateTime @@ -49,6 +48,12 @@ class HomeFragment : MainFragment() { private var _view: HomeView? = null; private var _cachedRecyclerData: FeedView.RecyclerData, GridLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null; + private var _cachedLastPager: IReusablePager? = null + + private var _toggleRecent = false; + private var _toggleWatched = false; + private var _togglePluginsDisabled = mutableListOf(); + fun reloadFeed() { _view?.reloadFeed() @@ -74,7 +79,7 @@ class HomeFragment : MainFragment() { } override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val view = HomeView(this, inflater, _cachedRecyclerData); + val view = HomeView(this, inflater, _cachedRecyclerData, _cachedLastPager); _view = view; return view; } @@ -92,6 +97,7 @@ class HomeFragment : MainFragment() { val view = _view; if (view != null) { _cachedRecyclerData = view.recyclerData; + _cachedLastPager = view.lastPager; view.cleanup(); _view = null; } @@ -111,9 +117,10 @@ class HomeFragment : MainFragment() { private val _taskGetPager: TaskHandler>; override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar - private var _lastPager: IReusablePager? = null; + var lastPager: IReusablePager? = null; - constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, GridLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null) : super(fragment, inflater, cachedRecyclerData) { + constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, GridLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null, cachedLastPager: IReusablePager? = null) : super(fragment, inflater, cachedRecyclerData) { + lastPager = cachedLastPager _taskGetPager = TaskHandler>({ fragment.lifecycleScope }, { StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope) }) @@ -122,7 +129,8 @@ class HomeFragment : MainFragment() { ReusableRefreshPager(it); else ReusablePager(it); - _lastPager = wrappedPager; + lastPager = wrappedPager; + resetAutomaticNextPageCounter(); loadedResult(wrappedPager.getWindow()); } .exception { } @@ -227,9 +235,6 @@ class HomeFragment : MainFragment() { } private val _filterLock = Object(); - private var _toggleRecent = false; - private var _toggleWatched = false; - private var _togglePluginsDisabled = mutableListOf(); private var _togglesConfig = FragmentedStorage.get("home_toggles"); fun initializeToolbarContent() { if(_toolbarContentView.allViews.any { it is ToggleBar }) @@ -245,18 +250,19 @@ class HomeFragment : MainFragment() { layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); } - _togglePluginsDisabled.clear(); + fragment._togglePluginsDisabled.clear(); synchronized(_filterLock) { val buttonsPlugins = (if (_togglesConfig.contains("plugins")) (StatePlatform.instance.getEnabledClients() + .filter { it is JSClient && it.enableInHome } .map { plugin -> - ToggleBar.Toggle(plugin.name, plugin.icon, true, { + ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) plugin.name else "", plugin.icon, !fragment._togglePluginsDisabled.contains(plugin.id), { if (it) { - if (_togglePluginsDisabled.contains(plugin.id)) - _togglePluginsDisabled.remove(plugin.id); + if (fragment._togglePluginsDisabled.contains(plugin.id)) + fragment._togglePluginsDisabled.remove(plugin.id); } else { - if (!_togglePluginsDisabled.contains(plugin.id)) - _togglePluginsDisabled.add(plugin.id); + if (!fragment._togglePluginsDisabled.contains(plugin.id)) + fragment._togglePluginsDisabled.add(plugin.id); } reloadForFilters(); }).withTag("plugins") @@ -264,13 +270,13 @@ class HomeFragment : MainFragment() { else listOf()) val buttons = (listOf( (if (_togglesConfig.contains("today")) - ToggleBar.Toggle("Today", _toggleRecent) { - _toggleRecent = it; reloadForFilters() + ToggleBar.Toggle("Today", fragment._toggleRecent) { + fragment._toggleRecent = it; reloadForFilters() } .withTag("today") else null), (if (_togglesConfig.contains("watched")) - ToggleBar.Toggle("Unwatched", _toggleWatched) { - _toggleWatched = it; reloadForFilters() + ToggleBar.Toggle("Unwatched", fragment._toggleWatched) { + fragment._toggleWatched = it; reloadForFilters() } .withTag("watched") else null), ).filterNotNull() + buttonsPlugins) @@ -302,7 +308,7 @@ class HomeFragment : MainFragment() { } } fun reloadForFilters() { - _lastPager?.let { loadedResult(it.getWindow()) }; + lastPager?.let { loadedResult(it.getWindow()) }; } override fun filterResults(results: List): List { @@ -312,11 +318,11 @@ class HomeFragment : MainFragment() { if(StateMeta.instance.isCreatorHidden(it.author.url)) return@filter false; - if(_toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25) + if(fragment._toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25) return@filter false; - if(_toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0)) + if(fragment._toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0)) return@filter false; - if(_togglePluginsDisabled.any() && it.id.pluginId != null && _togglePluginsDisabled.contains(it.id.pluginId)) { + if(fragment._togglePluginsDisabled.any() && it.id.pluginId != null && fragment._togglePluginsDisabled.contains(it.id.pluginId)) { return@filter false; } 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 b58e3ee2..c56585b0 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 @@ -326,6 +326,10 @@ class PlaylistFragment : MainFragment() { playlist.videos = ArrayList(playlist.videos.filter { it != video }); StatePlaylists.instance.createOrUpdatePlaylist(playlist); } + + override fun onVideoOptions(video: IPlatformVideo) { + UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer); + } override fun onVideoClicked(video: IPlatformVideo) { val playlist = _playlist; if (playlist != null) { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt index 9d188415..58caabe1 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt @@ -26,6 +26,8 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.views.SearchView import com.futo.platformplayer.views.adapters.* import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay @@ -82,7 +84,7 @@ class PlaylistsFragment : MainFragment() { private var _listPlaylistsSearch: EditText; - private var _ordering: String? = null; + private var _ordering = FragmentedStorage.get("playlists_ordering") constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) { @@ -145,24 +147,25 @@ class PlaylistsFragment : MainFragment() { spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.playlists_sortby_array)).also { it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); }; - spinnerSortBy.setSelection(0); + val options = listOf("nameAsc", "nameDesc", "dateEditAsc", "dateEditDesc", "dateCreateAsc", "dateCreateDesc", "datePlayAsc", "datePlayDesc"); spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { when(pos) { - 0 -> _ordering = "nameAsc" - 1 -> _ordering = "nameDesc" - 2 -> _ordering = "dateEditAsc" - 3 -> _ordering = "dateEditDesc" - 4 -> _ordering = "dateCreateAsc" - 5 -> _ordering = "dateCreateDesc" - 6 -> _ordering = "datePlayAsc" - 7 -> _ordering = "datePlayDesc" - else -> _ordering = null + 0 -> _ordering.setAndSave("nameAsc") + 1 -> _ordering.setAndSave("nameDesc") + 2 -> _ordering.setAndSave("dateEditAsc") + 3 -> _ordering.setAndSave("dateEditDesc") + 4 -> _ordering.setAndSave("dateCreateAsc") + 5 -> _ordering.setAndSave("dateCreateDesc") + 6 -> _ordering.setAndSave("datePlayAsc") + 7 -> _ordering.setAndSave("datePlayDesc") + else -> _ordering.setAndSave("") } updatePlaylistsFiltering() } override fun onNothingSelected(parent: AdapterView<*>?) = Unit }; + spinnerSortBy.setSelection(Math.max(0, options.indexOf(_ordering.value))); findViewById(R.id.text_view_all).setOnClickListener { _fragment.navigate(context.getString(R.string.watch_later)); }; @@ -214,8 +217,8 @@ class PlaylistsFragment : MainFragment() { var playlistsToReturn = pls; if(!_listPlaylistsSearch.text.isNullOrEmpty()) playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) }; - if(!_ordering.isNullOrEmpty()){ - playlistsToReturn = when(_ordering){ + if(!_ordering.value.isNullOrEmpty()){ + playlistsToReturn = when(_ordering.value){ "nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() } "nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() }; "dateEditAsc" -> playlistsToReturn.sortedBy { it.dateUpdate ?: OffsetDateTime.MAX }; 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 25b03cb1..7176d125 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 @@ -693,6 +693,9 @@ class VideoDetailView : ConstraintLayout { _container_content_description.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_liveChat.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_queue.onClose.subscribe { switchContentView(_container_content_main); }; + _container_content_queue.onOptions.subscribe { + UISlideOverlays.showVideoOptionsOverlay(it, _overlayContainer); + } _container_content_replies.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_support.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_browser.onClose.subscribe { switchContentView(_container_content_main); }; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt index 441f7421..c0383b89 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt @@ -104,6 +104,7 @@ abstract class VideoListEditorView : LinearLayout { videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged); videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved); + videoListEditorView.onVideoOptions.subscribe(::onVideoOptions); videoListEditorView.onVideoClicked.subscribe(::onVideoClicked); _videoListEditorView = videoListEditorView; @@ -122,6 +123,7 @@ abstract class VideoListEditorView : LinearLayout { open fun onShuffleClick() { } open fun onEditClick() { } open fun onVideoRemoved(video: IPlatformVideo) {} + open fun onVideoOptions(video: IPlatformVideo) {} open fun onVideoOrderChanged(videos : List) {} open fun onVideoClicked(video: IPlatformVideo) { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WatchLaterFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WatchLaterFragment.kt index 4d3c65bd..9d66c3f7 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WatchLaterFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WatchLaterFragment.kt @@ -103,6 +103,9 @@ class WatchLaterFragment : MainFragment() { StatePlaylists.instance.removeFromWatchLater(video, true); } } + override fun onVideoOptions(video: IPlatformVideo) { + UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer); + } override fun onVideoClicked(video: IPlatformVideo) { val watchLater = StatePlaylists.instance.getWatchLater(); diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt index c92c80b0..1d1acff6 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -69,7 +69,7 @@ class StateSubscriptions { val onSubscriptionsChanged = Event2, Boolean>(); - private val _subsExchangeServer = "http://10.10.15.159"//"https://exchange.grayjay.app/"; + private val _subsExchangeServer = "https://exchange.grayjay.app/"; private val _subscriptionKey = FragmentedStorage.get("sub_exchange_key"); init { diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt index ce0e19c2..1a1d503b 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -82,23 +82,30 @@ abstract class SubscriptionsTaskFetchAlgorithm( var providedTasks: MutableList? = null; try { - val contractableTasks = - tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) }; - contract = - if (contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map { - ChannelRequest(it.url) - }.toTypedArray()) else null; - if (contract?.provided?.isNotEmpty() == true) - Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}"); - if (contract != null && contract.required.isNotEmpty()) { - providedTasks = mutableListOf() - for (task in tasks.toList()) { - if (!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) { - providedTasks.add(task); - tasks.remove(task); + val contractingTime = measureTimeMillis { + val contractableTasks = + tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) }; + contract = + if (contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map { + ChannelRequest(it.url) + }.toTypedArray()) else null; + if (contract?.provided?.isNotEmpty() == true) + Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}"); + if (contract != null && contract!!.required.isNotEmpty()) { + providedTasks = mutableListOf() + for (task in tasks.toList()) { + if (!task.fromCache && !task.fromPeek && contract!!.provided.contains(task.url)) { + providedTasks!!.add(task); + tasks.remove(task); + } } } } + if(contract != null) + Logger.i(TAG, "Subscription Exchange contract received in ${contractingTime}ms"); + else if(contractingTime > 100) + Logger.i(TAG, "Subscription Exchange contract failed to received in${contractingTime}ms"); + } catch(ex: Throwable){ Logger.e("SubscriptionsTaskFetchAlgorithm", "Failed to retrieve SubsExchange contract due to: " + ex.message, ex); @@ -109,6 +116,8 @@ abstract class SubscriptionsTaskFetchAlgorithm( val forkTasks = executeSubscriptionTasks(tasks, failedPlugins, cachedChannels); val taskResults = arrayListOf(); + var resolveCount = 0; + var resolveTime = 0L; val timeTotal = measureTimeMillis { for(task in forkTasks) { try { @@ -137,51 +146,68 @@ abstract class SubscriptionsTaskFetchAlgorithm( } }; } - } - //Resolve Subscription Exchange - if(contract != null) { - try { - val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract.required.contains(it.task.url) }.map { - ChannelResolve( - it.task.url, - it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) } - ) - }.toTypedArray() - val resolve = subsExchangeClient?.resolveContract( - contract, - *resolves - ); - if (resolve != null) { - val invalids = resolve.filter { it.content.any { it.datetime == null } }; - UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size})") - for(result in resolve){ - val task = providedTasks?.find { it.url == result.channelUrl }; - if(task != null) { - taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null)); - providedTasks?.remove(task); + //Resolve Subscription Exchange + if(contract != null) { + try { + resolveTime = measureTimeMillis { + val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract!!.required.contains(it.task.url) }.map { + ChannelResolve( + it.task.url, + it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) } + ) + }.toTypedArray() + val resolve = subsExchangeClient?.resolveContract( + contract!!, + *resolves + ); + if (resolve != null) { + resolveCount = resolves.size; + UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size}") + for(result in resolve){ + val task = providedTasks?.find { it.url == result.channelUrl }; + if(task != null) { + taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null)); + providedTasks?.remove(task); + } + } + } + if (providedTasks != null) { + for(task in providedTasks!!) { + taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange"))); + } } } + Logger.i(TAG, "Subscription Exchange contract resolved in ${resolveTime}ms"); + } - if (providedTasks != null) { - for(task in providedTasks) { - taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange"))); - } + catch(ex: Throwable) { + //TODO: fetch remainder after all? + Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex); } } - catch(ex: Throwable) { - //TODO: fetch remainder after all? - Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex); - } } - Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms") + Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms"); + if(resolveCount > 0) { + val selfFetchTime = timeTotal - resolveTime; + val selfFetchCount = tasks.count { !it.fromPeek && !it.fromCache }; + if(selfFetchCount > 0) { + val selfResolvePercentage = resolveCount.toDouble() / selfFetchCount; + val estimateSelfFetchTime = selfFetchTime + selfFetchTime * selfResolvePercentage; + val selfFetchDelta = timeTotal - estimateSelfFetchTime; + if(selfFetchDelta > 0) + UIDialogs.appToast("Subscription Exchange lost ${selfFetchDelta}ms (out of ${timeTotal}ms)", true); + else + UIDialogs.appToast("Subscription Exchange saved ${(selfFetchDelta * -1).toInt()}ms (out of ${timeTotal}ms)", true); + } + } //Cache pagers grouped by channel val groupedPagers = taskResults.groupBy { it.task.sub.channel.url } .map { entry -> val sub = if(!entry.value.isEmpty()) entry.value[0].task.sub else null; - val liveTasks = entry.value.filter { !it.task.fromCache }; + val liveTasks = entry.value.filter { !it.task.fromCache && it.pager != null }; val cachedTasks = entry.value.filter { it.task.fromCache }; val livePager = if(liveTasks.isNotEmpty()) StateCache.cachePagerResults(scope, MultiChronoContentPager(liveTasks.map { it.pager!! }, true).apply { this.initialize() }) { onNewCacheHit.emit(sub!!, it); diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt index 0a55516f..6f38014d 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt @@ -28,7 +28,7 @@ import java.security.spec.PKCS8EncodedKeySpec import java.security.spec.RSAPublicKeySpec -class SubsExchangeClient(private val server: String, private val privateKey: String) { +class SubsExchangeClient(private val server: String, private val privateKey: String, private val contractTimeout: Int = 1000) { private val json = Json { ignoreUnknownKeys = true @@ -40,7 +40,7 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str // Endpoint: Contract fun requestContract(vararg channels: ChannelRequest): ExchangeContract { - val data = post("/api/Channel/Contract", Json.encodeToString(channels), "application/json") + val data = post("/api/Channel/Contract", Json.encodeToString(channels), "application/json", contractTimeout) return Json.decodeFromString(data) } suspend fun requestContractAsync(vararg channels: ChannelRequest): ExchangeContract { @@ -74,9 +74,11 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str } // IO methods - private fun post(query: String, body: String, contentType: String): String { + private fun post(query: String, body: String, contentType: String, timeout: Int = 0): String { val url = URL("${server.trim('/')}$query") with(url.openConnection() as HttpURLConnection) { + if(timeout > 0) + this.connectTimeout = timeout requestMethod = "POST" setRequestProperty("Content-Type", contentType) doOutput = true diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt index aa4ee66f..f7c313f5 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt @@ -14,6 +14,7 @@ class VideoListEditorAdapter : RecyclerView.Adapter { val onClick = Event1(); val onRemove = Event1(); + val onOptions = Event1(); var canEdit = false private set; @@ -28,6 +29,7 @@ class VideoListEditorAdapter : RecyclerView.Adapter { val holder = VideoListEditorViewHolder(view, _touchHelper); holder.onRemove.subscribe { v -> onRemove.emit(v); }; + holder.onOptions.subscribe { v -> onOptions.emit(v); }; holder.onClick.subscribe { v -> onClick.emit(v); }; return holder; 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 3cf3194b..77df0665 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 @@ -32,6 +32,7 @@ class VideoListEditorViewHolder : ViewHolder { private val _containerDuration: LinearLayout; private val _containerLive: LinearLayout; private val _imageRemove: ImageButton; + private val _imageOptions: ImageButton; private val _imageDragDrop: ImageButton; private val _platformIndicator: PlatformIndicator; private val _layoutDownloaded: FrameLayout; @@ -41,6 +42,7 @@ class VideoListEditorViewHolder : ViewHolder { val onClick = Event1(); val onRemove = Event1(); + val onOptions = Event1(); @SuppressLint("ClickableViewAccessibility") constructor(view: View, touchHelper: ItemTouchHelper? = null) : super(view) { @@ -54,6 +56,7 @@ class VideoListEditorViewHolder : ViewHolder { _containerDuration = view.findViewById(R.id.thumbnail_duration_container); _containerLive = view.findViewById(R.id.thumbnail_live_container); _imageRemove = view.findViewById(R.id.image_trash); + _imageOptions = view.findViewById(R.id.image_settings); _imageDragDrop = view.findViewById(R.id.image_drag_drop); _platformIndicator = view.findViewById(R.id.thumbnail_platform); _layoutDownloaded = view.findViewById(R.id.layout_downloaded); @@ -74,6 +77,10 @@ class VideoListEditorViewHolder : ViewHolder { val v = video ?: return@setOnClickListener; onRemove.emit(v); }; + _imageOptions?.setOnClickListener { + val v = video ?: return@setOnClickListener; + onOptions.emit(v); + } } fun bind(v: IPlatformVideo, canEdit: Boolean) { diff --git a/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt index 3bfce0be..08d32ac3 100644 --- a/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt @@ -8,6 +8,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 @@ -22,6 +23,7 @@ class VideoListEditorView : FrameLayout { val onVideoOrderChanged = Event1>() val onVideoRemoved = Event1(); + val onVideoOptions = Event1(); val onVideoClicked = Event1(); val isEmpty get() = _videos.isEmpty(); @@ -54,6 +56,9 @@ class VideoListEditorView : FrameLayout { } }; + adapterVideos.onOptions.subscribe { v -> + onVideoOptions?.emit(v); + } adapterVideos.onRemove.subscribe { v -> val executeDelete = { synchronized(_videos) { diff --git a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt index 65b13eb0..059405ad 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt @@ -58,6 +58,7 @@ class ToggleTagView : LinearLayout { setToggle(isActive); _image.setImageResource(imageResource); _image.visibility = View.VISIBLE; + _textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE; this.isButton = isButton; } fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false) { @@ -66,12 +67,14 @@ class ToggleTagView : LinearLayout { setToggle(isActive); image.setImageView(_image, R.drawable.ic_error_pred); _image.visibility = View.VISIBLE; + _textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE; this.isButton = isButton; } fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false) { _image.visibility = View.GONE; _text = text; _textTag.text = text; + _textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE; setToggle(isActive); this.isButton = isButton; } diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt index a7181e90..53982097 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt @@ -8,7 +8,9 @@ import android.widget.LinearLayout import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.R import com.futo.platformplayer.UISlideOverlays +import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.views.lists.VideoListEditorView import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay @@ -23,6 +25,7 @@ class QueueEditorOverlay : LinearLayout { private val _overlayContainer: FrameLayout; + val onOptions = Event1(); val onClose = Event0(); constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { @@ -35,6 +38,9 @@ class QueueEditorOverlay : LinearLayout { _topbar.onClose.subscribe(this, onClose::emit); _editor.onVideoOrderChanged.subscribe { StatePlayer.instance.setQueueWithExisting(it) } + _editor.onVideoOptions.subscribe { v -> + onOptions?.emit(v); + } _editor.onVideoRemoved.subscribe { v -> StatePlayer.instance.removeFromQueue(v); _topbar.setInfo(context.getString(R.string.queue), "${StatePlayer.instance.queueSize} " + context.getString(R.string.videos)); diff --git a/app/src/main/res/layout/list_playlist.xml b/app/src/main/res/layout/list_playlist.xml index d51cdfc5..c9ea9927 100644 --- a/app/src/main/res/layout/list_playlist.xml +++ b/app/src/main/res/layout/list_playlist.xml @@ -135,7 +135,7 @@ android:ellipsize="end" app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail" app:layout_constraintTop_toTopOf="parent" - app:layout_constraintRight_toLeftOf="@id/image_trash" + app:layout_constraintRight_toLeftOf="@id/buttons" app:layout_constraintBottom_toTopOf="@id/text_author" android:layout_marginStart="10dp" /> @@ -152,7 +152,7 @@ android:ellipsize="end" app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail" app:layout_constraintTop_toBottomOf="@id/text_video_name" - app:layout_constraintRight_toLeftOf="@id/image_trash" + app:layout_constraintRight_toLeftOf="@id/buttons" app:layout_constraintBottom_toTopOf="@id/text_video_metadata" android:layout_marginStart="10dp" /> @@ -169,19 +169,35 @@ android:ellipsize="end" app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail" app:layout_constraintTop_toBottomOf="@id/text_author" - app:layout_constraintRight_toLeftOf="@id/image_trash" + app:layout_constraintRight_toLeftOf="@id/buttons" android:layout_marginStart="10dp" /> - + app:layout_constraintBottom_toBottomOf="@id/layout_video_thumbnail" > + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_toggle_tag.xml b/app/src/main/res/layout/view_toggle_tag.xml index eca7010c..5f285bd2 100644 --- a/app/src/main/res/layout/view_toggle_tag.xml +++ b/app/src/main/res/layout/view_toggle_tag.xml @@ -3,8 +3,8 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="wrap_content" android:layout_height="32dp" - android:paddingStart="15dp" - android:paddingEnd="15dp" + android:paddingStart="12dp" + android:paddingEnd="12dp" android:background="@drawable/background_pill" android:layout_marginEnd="6dp" android:layout_marginTop="17dp" @@ -19,12 +19,15 @@ android:visibility="gone" android:layout_width="24dp" android:layout_height="24dp" - android:layout_marginRight="5dp" - android:layout_marginTop="4dp" /> + android:layout_gravity="center" + android:layout_marginLeft="2.5dp" + android:layout_marginRight="2.5dp" /> When the preview feedstyle is used, if items should auto-preview when scrolling over them Show Home Filters If the home filters should be shown above home + Home filter Plugin Names + If home filters should show full plugin names or just icons Log Level Logging Sync Grayjay From 7b355139fb9fbc79578af84c5b65aabec41faa07 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Mon, 7 Apr 2025 23:31:00 +0200 Subject: [PATCH 16/19] Subscription persistence fixes, home toggle fixes, subs exchange gzip, etc --- .../java/com/futo/platformplayer/Utility.kt | 35 ++++++++ .../mainactivity/main/HomeFragment.kt | 38 ++++++--- .../main/SubscriptionsFeedFragment.kt | 41 ++++++---- .../SubscriptionsTaskFetchAlgorithm.kt | 80 ++++++++++++------- .../subsexchange/SubsExchangeClient.kt | 35 +++++--- .../futo/platformplayer/views/ToggleBar.kt | 10 +-- .../views/others/ToggleTagView.kt | 24 +++++- .../views/subscriptions/SubscriptionBar.kt | 8 +- 8 files changed, 191 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Utility.kt b/app/src/main/java/com/futo/platformplayer/Utility.kt index 64efb992..5cd5d26f 100644 --- a/app/src/main/java/com/futo/platformplayer/Utility.kt +++ b/app/src/main/java/com/futo/platformplayer/Utility.kt @@ -27,14 +27,18 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.PlatformVideoWithTime import com.futo.platformplayer.others.PlatformLinkMovementMethod import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.nio.ByteBuffer import java.nio.ByteOrder +import java.time.OffsetDateTime import java.util.* import java.util.concurrent.ThreadLocalRandom +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz "; fun getRandomString(sizeOfRandomString: Int): String { @@ -279,3 +283,34 @@ fun ByteBuffer.toUtf8String(): String { get(remainingBytes) return String(remainingBytes, Charsets.UTF_8) } + + +fun ByteArray.toGzip(): ByteArray { + if (this == null || this.isEmpty()) return ByteArray(0) + + val gzipTimeStart = OffsetDateTime.now(); + + val outputStream = ByteArrayOutputStream() + GZIPOutputStream(outputStream).use { gzip -> + gzip.write(this) + } + val result = outputStream.toByteArray(); + Logger.i("Utility", "Gzip compression time: ${gzipTimeStart.getNowDiffMiliseconds()}ms"); + return result; +} + +fun ByteArray.fromGzip(): ByteArray { + if (this == null || this.isEmpty()) return ByteArray(0) + + val inputStream = ByteArrayInputStream(this) + val outputStream = ByteArrayOutputStream() + + GZIPInputStream(inputStream).use { gzip -> + val buffer = ByteArray(1024) + var bytesRead: Int + while (gzip.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + } + } + return outputStream.toByteArray() +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt index a450a2ed..988d7a3f 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt @@ -250,39 +250,53 @@ class HomeFragment : MainFragment() { layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); } - fragment._togglePluginsDisabled.clear(); + synchronized(_filterLock) { - val buttonsPlugins = (if (_togglesConfig.contains("plugins")) + var buttonsPlugins: List = listOf() + buttonsPlugins = (if (_togglesConfig.contains("plugins")) (StatePlatform.instance.getEnabledClients() .filter { it is JSClient && it.enableInHome } .map { plugin -> - ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) plugin.name else "", plugin.icon, !fragment._togglePluginsDisabled.contains(plugin.id), { - if (it) { + ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) plugin.name else "", plugin.icon, !fragment._togglePluginsDisabled.contains(plugin.id), { view, active -> + var dontSwap = false; + if (active) { if (fragment._togglePluginsDisabled.contains(plugin.id)) fragment._togglePluginsDisabled.remove(plugin.id); } else { - if (!fragment._togglePluginsDisabled.contains(plugin.id)) - fragment._togglePluginsDisabled.add(plugin.id); + if (!fragment._togglePluginsDisabled.contains(plugin.id)) { + val enabledClients = StatePlatform.instance.getEnabledClients(); + val availableAfterDisable = enabledClients.count { !fragment._togglePluginsDisabled.contains(it.id) && it.id != plugin.id }; + if(availableAfterDisable > 0) + fragment._togglePluginsDisabled.add(plugin.id); + else { + UIDialogs.appToast("Home needs atleast 1 plugin active"); + dontSwap = true; + } + } + } + if(!dontSwap) + reloadForFilters(); + else { + view.setToggle(!active); } - reloadForFilters(); }).withTag("plugins") }) else listOf()) val buttons = (listOf( (if (_togglesConfig.contains("today")) - ToggleBar.Toggle("Today", fragment._toggleRecent) { - fragment._toggleRecent = it; reloadForFilters() + ToggleBar.Toggle("Today", fragment._toggleRecent) { view, active -> + fragment._toggleRecent = active; reloadForFilters() } .withTag("today") else null), (if (_togglesConfig.contains("watched")) - ToggleBar.Toggle("Unwatched", fragment._toggleWatched) { - fragment._toggleWatched = it; reloadForFilters() + ToggleBar.Toggle("Unwatched", fragment._toggleWatched) { view, active -> + fragment._toggleWatched = active; reloadForFilters() } .withTag("watched") else null), ).filterNotNull() + buttonsPlugins) .sortedBy { _togglesConfig.indexOf(it.tag ?: "") } ?: listOf() - val buttonSettings = ToggleBar.Toggle("", R.drawable.ic_settings, true, { + val buttonSettings = ToggleBar.Toggle("", R.drawable.ic_settings, true, { view, active -> showOrderOverlay(_overlayContainer, "Visible home filters", listOf( diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt index b4f51b14..83e39a88 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt @@ -18,6 +18,7 @@ import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.exceptions.RateLimitException +import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment.SubscriptionsFeedView.FeedFilterSettings import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.SearchType import com.futo.platformplayer.models.SubscriptionGroup @@ -56,6 +57,9 @@ class SubscriptionsFeedFragment : MainFragment() { private var _group: SubscriptionGroup? = null; private var _cachedRecyclerData: FeedView.RecyclerData, GridLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null; + private val _filterLock = Object(); + private val _filterSettings = FragmentedStorage.get("subFeedFilter"); + override fun onShownWithView(parameter: Any?, isBack: Boolean) { super.onShownWithView(parameter, isBack); _view?.onShown(); @@ -184,8 +188,6 @@ class SubscriptionsFeedFragment : MainFragment() { return Json.encodeToString(this); } } - private val _filterLock = Object(); - private val _filterSettings = FragmentedStorage.get("subFeedFilter"); private var _bypassRateLimit = false; private val _lastExceptions: List? = null; @@ -284,13 +286,18 @@ class SubscriptionsFeedFragment : MainFragment() { fragment.navigate(g); }; - synchronized(_filterLock) { + synchronized(fragment._filterLock) { _subscriptionBar?.setToggles( - SubscriptionBar.Toggle(context.getString(R.string.videos), _filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), it); }, - SubscriptionBar.Toggle(context.getString(R.string.posts), _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); }, - SubscriptionBar.Toggle(context.getString(R.string.live), _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); }, - SubscriptionBar.Toggle(context.getString(R.string.planned), _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); }, - SubscriptionBar.Toggle(context.getString(R.string.watched), _filterSettings.allowWatched) { _filterSettings.allowWatched = it; _filterSettings.save(); loadResults(false); } + SubscriptionBar.Toggle(context.getString(R.string.videos), fragment._filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { view, active -> + toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), active); }, + SubscriptionBar.Toggle(context.getString(R.string.posts), fragment._filterSettings.allowContentTypes.contains(ContentType.POST)) { view, active -> + toggleFilterContentType(ContentType.POST, active); }, + SubscriptionBar.Toggle(context.getString(R.string.live), fragment._filterSettings.allowLive) { view, active -> + fragment._filterSettings.allowLive = active; fragment._filterSettings.save(); loadResults(false); }, + SubscriptionBar.Toggle(context.getString(R.string.planned), fragment._filterSettings.allowPlanned) { view, active -> + fragment._filterSettings.allowPlanned = active; fragment._filterSettings.save(); loadResults(false); }, + SubscriptionBar.Toggle(context.getString(R.string.watched), fragment._filterSettings.allowWatched) { view, active -> + fragment._filterSettings.allowWatched = active; fragment._filterSettings.save(); loadResults(false); } ); } @@ -301,13 +308,13 @@ class SubscriptionsFeedFragment : MainFragment() { toggleFilterContentType(contentType, isTrue); } private fun toggleFilterContentType(contentType: ContentType, isTrue: Boolean) { - synchronized(_filterLock) { + synchronized(fragment._filterLock) { if(!isTrue) { - _filterSettings.allowContentTypes.remove(contentType); - } else if(!_filterSettings.allowContentTypes.contains(contentType)) { - _filterSettings.allowContentTypes.add(contentType) + fragment._filterSettings.allowContentTypes.remove(contentType); + } else if(!fragment._filterSettings.allowContentTypes.contains(contentType)) { + fragment._filterSettings.allowContentTypes.add(contentType) } - _filterSettings.save(); + fragment._filterSettings.save(); }; if(Settings.instance.subscriptions.fetchOnTabOpen) { //TODO: Do this different, temporary workaround loadResults(false); @@ -320,9 +327,9 @@ class SubscriptionsFeedFragment : MainFragment() { val nowSoon = OffsetDateTime.now().plusMinutes(5); val filterGroup = subGroup; return results.filter { - val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType); + val allowedContentType = fragment._filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType); - if(it is IPlatformVideo && it.duration > 0 && !_filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration)) + if(it is IPlatformVideo && it.duration > 0 && !fragment._filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration)) return@filter false; //TODO: Check against a sub cache @@ -331,11 +338,11 @@ class SubscriptionsFeedFragment : MainFragment() { if(it.datetime?.isAfter(nowSoon) == true) { - if(!_filterSettings.allowPlanned) + if(!fragment._filterSettings.allowPlanned) return@filter false; } - if(_filterSettings.allowLive) { //If allowLive, always show live + if(fragment._filterSettings.allowLive) { //If allowLive, always show live if(it is IPlatformVideo && it.isLive) return@filter true; } diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt index 1a1d503b..b72e840c 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -15,12 +15,14 @@ import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.MultiChronoContentPager import com.futo.platformplayer.api.media.structures.PlatformContentPager +import com.futo.platformplayer.debug.Stopwatch import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCriticalException import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.findNonRuntimeException import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment +import com.futo.platformplayer.getNowDiffMiliseconds import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.states.StateApp @@ -32,6 +34,8 @@ import com.futo.platformplayer.subsexchange.ChannelRequest import com.futo.platformplayer.subsexchange.ChannelResolve import com.futo.platformplayer.subsexchange.ExchangeContract import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.time.OffsetDateTime import java.util.concurrent.ExecutionException import java.util.concurrent.ForkJoinPool @@ -149,42 +153,56 @@ abstract class SubscriptionsTaskFetchAlgorithm( //Resolve Subscription Exchange if(contract != null) { - try { - resolveTime = measureTimeMillis { - val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract!!.required.contains(it.task.url) }.map { - ChannelResolve( - it.task.url, - it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) } - ) - }.toTypedArray() - val resolve = subsExchangeClient?.resolveContract( - contract!!, - *resolves - ); - if (resolve != null) { - resolveCount = resolves.size; - UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size}") - for(result in resolve){ - val task = providedTasks?.find { it.url == result.channelUrl }; - if(task != null) { - taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null)); - providedTasks?.remove(task); + fun resolve() { + try { + resolveTime = measureTimeMillis { + val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract!!.required.contains(it.task.url) }.map { + ChannelResolve( + it.task.url, + it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) } + ) + }.toTypedArray() + + val resolveRequestStart = OffsetDateTime.now(); + + val resolve = subsExchangeClient?.resolveContract( + contract!!, + *resolves + ); + + Logger.i(TAG, "Subscription Exchange contract resolved request in ${resolveRequestStart.getNowDiffMiliseconds()}ms"); + + if (resolve != null) { + resolveCount = resolves.size; + UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size}") + for(result in resolve){ + val task = providedTasks?.find { it.url == result.channelUrl }; + if(task != null) { + taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null)); + providedTasks?.remove(task); + } + } + } + if (providedTasks != null) { + for(task in providedTasks!!) { + taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange"))); } } } - if (providedTasks != null) { - for(task in providedTasks!!) { - taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange"))); - } - } - } - Logger.i(TAG, "Subscription Exchange contract resolved in ${resolveTime}ms"); + Logger.i(TAG, "Subscription Exchange contract resolved in ${resolveTime}ms"); + } + catch(ex: Throwable) { + //TODO: fetch remainder after all? + Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex); + } } - catch(ex: Throwable) { - //TODO: fetch remainder after all? - Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex); - } + if(providedTasks?.size ?: 0 == 0) + scope.launch(Dispatchers.IO) { + resolve(); + } + else + resolve(); } } diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt index 6f38014d..b0357f56 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt @@ -1,10 +1,14 @@ import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.getNowDiffMiliseconds import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm.Companion.TAG import com.futo.platformplayer.subsexchange.ChannelRequest import com.futo.platformplayer.subsexchange.ChannelResolve import com.futo.platformplayer.subsexchange.ChannelResult import com.futo.platformplayer.subsexchange.ExchangeContract import com.futo.platformplayer.subsexchange.ExchangeContractResolve +import com.futo.platformplayer.toGzip +import com.futo.platformplayer.toHumanBytesSize import kotlinx.serialization.* import kotlinx.serialization.json.* import kotlinx.coroutines.Dispatchers @@ -26,6 +30,7 @@ import java.nio.charset.StandardCharsets import java.security.KeyPairGenerator import java.security.spec.PKCS8EncodedKeySpec import java.security.spec.RSAPublicKeySpec +import java.time.OffsetDateTime class SubsExchangeClient(private val server: String, private val privateKey: String, private val contractTimeout: Int = 1000) { @@ -40,24 +45,27 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str // Endpoint: Contract fun requestContract(vararg channels: ChannelRequest): ExchangeContract { - val data = post("/api/Channel/Contract", Json.encodeToString(channels), "application/json", contractTimeout) + val data = post("/api/Channel/Contract", Json.encodeToString(channels).toByteArray(Charsets.UTF_8), "application/json", contractTimeout) return Json.decodeFromString(data) } suspend fun requestContractAsync(vararg channels: ChannelRequest): ExchangeContract { - val data = postAsync("/api/Channel/Contract", Json.encodeToString(channels), "application/json") + val data = postAsync("/api/Channel/Contract", Json.encodeToString(channels).toByteArray(Charsets.UTF_8), "application/json") return Json.decodeFromString(data) } // Endpoint: Resolve fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { val contractResolve = convertResolves(*resolves) - val result = post("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json") - Logger.v("SubsExchangeClient", "Resolve:" + result); + val contractResolveJson = Serializer.json.encodeToString(contractResolve); + val contractResolveTimeStart = OffsetDateTime.now(); + val result = post("/api/Channel/Resolve?contractId=${contract.id}", contractResolveJson.toByteArray(Charsets.UTF_8), "application/json", 0, true) + val contractResolveTime = contractResolveTimeStart.getNowDiffMiliseconds(); + Logger.v("SubsExchangeClient", "Subscription Exchange Resolve Request [${contractResolveTime}ms]:" + result); return Serializer.json.decodeFromString(result) } suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { val contractResolve = convertResolves(*resolves) - val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json") + val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve).toByteArray(Charsets.UTF_8), "application/json", true) return Serializer.json.decodeFromString(result) } @@ -74,7 +82,7 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str } // IO methods - private fun post(query: String, body: String, contentType: String, timeout: Int = 0): String { + private fun post(query: String, body: ByteArray, contentType: String, timeout: Int = 0, gzip: Boolean = false): String { val url = URL("${server.trim('/')}$query") with(url.openConnection() as HttpURLConnection) { if(timeout > 0) @@ -82,7 +90,16 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str requestMethod = "POST" setRequestProperty("Content-Type", contentType) doOutput = true - OutputStreamWriter(outputStream, StandardCharsets.UTF_8).use { it.write(body); it.flush() } + + + if(gzip) { + val gzipData = body.toGzip(); + setRequestProperty("Content-Encoding", "gzip"); + outputStream.write(gzipData); + Logger.i("SubsExchangeClient", "SubsExchange using gzip (${body.size.toHumanBytesSize()} => ${gzipData.size.toHumanBytesSize()}"); + } + else + outputStream.write(body); val status = responseCode; Logger.i("SubsExchangeClient", "POST [${url}]: ${status}"); @@ -105,9 +122,9 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str } } } - private suspend fun postAsync(query: String, body: String, contentType: String): String { + private suspend fun postAsync(query: String, body: ByteArray, contentType: String, gzip: Boolean = false): String { return withContext(Dispatchers.IO) { - post(query, body, contentType) + post(query, body, contentType, 0, gzip) } } diff --git a/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt b/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt index ef2eadbc..4a545a26 100644 --- a/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt +++ b/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt @@ -53,7 +53,7 @@ class ToggleBar : LinearLayout { this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton); else this.setInfo(button.name, button.isActive, button.isButton); - this.onClick.subscribe { button.action(it); }; + this.onClick.subscribe({ view, enabled -> button.action(view, enabled); }); }); } } @@ -62,27 +62,27 @@ class ToggleBar : LinearLayout { val name: String; val icon: Int; val iconVariable: ImageVariable?; - val action: (Boolean)->Unit; + val action: (ToggleTagView, Boolean)->Unit; val isActive: Boolean; var isButton: Boolean = false private set; var tag: String? = null; - constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (Boolean)->Unit) { + constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { this.name = name; this.icon = 0; this.iconVariable = icon; this.action = action; this.isActive = isActive; } - constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) { + constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { this.name = name; this.icon = icon; this.iconVariable = null; this.action = action; this.isActive = isActive; } - constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) { + constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { this.name = name; this.icon = 0; this.iconVariable = null; diff --git a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt index 059405ad..3ba65413 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt @@ -12,8 +12,10 @@ import android.widget.TextView import com.bumptech.glide.Glide import com.futo.platformplayer.R import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.images.GlideHelper import com.futo.platformplayer.models.ImageVariable +import com.futo.platformplayer.views.ToggleBar class ToggleTagView : LinearLayout { private val _root: FrameLayout; @@ -26,7 +28,7 @@ class ToggleTagView : LinearLayout { var isButton: Boolean = false private set; - var onClick = Event1(); + var onClick = Event2(); constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true); @@ -36,7 +38,7 @@ class ToggleTagView : LinearLayout { _root.setOnClickListener { if(!isButton) setToggle(!isActive); - onClick.emit(isActive); + onClick.emit(this, isActive); } } @@ -52,6 +54,24 @@ class ToggleTagView : LinearLayout { } } + fun setInfo(toggle: ToggleBar.Toggle){ + _text = toggle.name; + _textTag.text = toggle.name; + setToggle(toggle.isActive); + if(toggle.iconVariable != null) { + toggle.iconVariable.setImageView(_image, R.drawable.ic_error_pred); + _image.visibility = View.GONE; + } + else if(toggle.icon > 0) { + _image.setImageResource(toggle.icon); + _image.visibility = View.GONE; + } + else + _image.visibility = View.VISIBLE; + _textTag.visibility = if(!toggle.name.isNullOrEmpty()) View.VISIBLE else View.GONE; + this.isButton = isButton; + } + fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false) { _text = text; _textTag.text = text; diff --git a/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscriptionBar.kt b/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscriptionBar.kt index 97eecac4..5cd2ad85 100644 --- a/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscriptionBar.kt +++ b/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscriptionBar.kt @@ -158,7 +158,7 @@ class SubscriptionBar : LinearLayout { for(button in buttons) { _tagsContainer.addView(ToggleTagView(context).apply { this.setInfo(button.name, button.isActive); - this.onClick.subscribe { button.action(it); }; + this.onClick.subscribe({ view, value -> button.action(view, value); }); }); } } @@ -166,16 +166,16 @@ class SubscriptionBar : LinearLayout { class Toggle { val name: String; val icon: Int; - val action: (Boolean)->Unit; + val action: (ToggleTagView, Boolean)->Unit; val isActive: Boolean; - constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) { + constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { this.name = name; this.icon = icon; this.action = action; this.isActive = isActive; } - constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) { + constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { this.name = name; this.icon = 0; this.action = action; From ce2a2f85823ca90ca5e07780b8f74b2fb5c23bf1 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Mon, 7 Apr 2025 23:32:57 +0200 Subject: [PATCH 17/19] submods --- app/src/stable/assets/sources/odysee | 2 +- app/src/stable/assets/sources/soundcloud | 2 +- app/src/stable/assets/sources/spotify | 2 +- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/assets/sources/odysee | 2 +- app/src/unstable/assets/sources/soundcloud | 2 +- app/src/unstable/assets/sources/spotify | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/stable/assets/sources/odysee b/app/src/stable/assets/sources/odysee index f2f83344..215cd9bd 160000 --- a/app/src/stable/assets/sources/odysee +++ b/app/src/stable/assets/sources/odysee @@ -1 +1 @@ -Subproject commit f2f83344ebc905b36c0689bfef407bb95e6d9af0 +Subproject commit 215cd9bd70d3cc68e25441f7696dcbe5beee2709 diff --git a/app/src/stable/assets/sources/soundcloud b/app/src/stable/assets/sources/soundcloud index bff981c3..f8234d6a 160000 --- a/app/src/stable/assets/sources/soundcloud +++ b/app/src/stable/assets/sources/soundcloud @@ -1 +1 @@ -Subproject commit bff981c3ce7abad363e79705214a4710fb347f7d +Subproject commit f8234d6af8573414d07fd364bc136aa67ad0e379 diff --git a/app/src/stable/assets/sources/spotify b/app/src/stable/assets/sources/spotify index 331dd929..b61095ec 160000 --- a/app/src/stable/assets/sources/spotify +++ b/app/src/stable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 331dd929293614875af80e3ab4cb162dc6183410 +Subproject commit b61095ec200284a686edb8f3b2a595599ad8b5ed diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index ae7b62f4..6f1266a0 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit ae7b62f4d85cf398d9566a6ed07a6afc193f6b48 +Subproject commit 6f1266a038d11998fef429ae0eac0798b3280d75 diff --git a/app/src/unstable/assets/sources/odysee b/app/src/unstable/assets/sources/odysee index f2f83344..215cd9bd 160000 --- a/app/src/unstable/assets/sources/odysee +++ b/app/src/unstable/assets/sources/odysee @@ -1 +1 @@ -Subproject commit f2f83344ebc905b36c0689bfef407bb95e6d9af0 +Subproject commit 215cd9bd70d3cc68e25441f7696dcbe5beee2709 diff --git a/app/src/unstable/assets/sources/soundcloud b/app/src/unstable/assets/sources/soundcloud index bff981c3..f8234d6a 160000 --- a/app/src/unstable/assets/sources/soundcloud +++ b/app/src/unstable/assets/sources/soundcloud @@ -1 +1 @@ -Subproject commit bff981c3ce7abad363e79705214a4710fb347f7d +Subproject commit f8234d6af8573414d07fd364bc136aa67ad0e379 diff --git a/app/src/unstable/assets/sources/spotify b/app/src/unstable/assets/sources/spotify index 331dd929..b61095ec 160000 --- a/app/src/unstable/assets/sources/spotify +++ b/app/src/unstable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 331dd929293614875af80e3ab4cb162dc6183410 +Subproject commit b61095ec200284a686edb8f3b2a595599ad8b5ed diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index ae7b62f4..6f1266a0 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit ae7b62f4d85cf398d9566a6ed07a6afc193f6b48 +Subproject commit 6f1266a038d11998fef429ae0eac0798b3280d75 From 869b1fc15e4ab3665b9951f6460194e74ab2f372 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Tue, 8 Apr 2025 00:34:52 +0200 Subject: [PATCH 18/19] Fix pager for landscape --- .../platformplayer/fragment/mainactivity/main/FeedView.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt index 17ca5510..3c915ebe 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt @@ -197,10 +197,12 @@ abstract class FeedView : L val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null; val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition(); val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null; + val rows = if(recyclerData.layoutManager is GridLayoutManager) Math.max(1, recyclerData.results.size / recyclerData.layoutManager.spanCount) else 1; + val rowsHeight = (firstVisibleItemView?.height ?: 0) * rows; if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) { false; } - else if (firstVisibleItemView != null && height != null && firstVisibleItemView.height * recyclerData.results.size < height) { + else if (firstVisibleItemView != null && height != null && rowsHeight < height) { false; } else { true; From 1755d03a6bd402ba9980e4683ee0899e60be0b4a Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 9 Apr 2025 00:56:49 +0200 Subject: [PATCH 19/19] Fcast clearer connection/reconnection overlay, disable ipv6 by default --- .../futo/platformplayer/Extensions_Network.kt | 7 +++- .../java/com/futo/platformplayer/Settings.kt | 7 +++- .../java/com/futo/platformplayer/UIDialogs.kt | 11 ++++- .../casting/FCastCastingDevice.kt | 2 + .../platformplayer/casting/StateCasting.kt | 40 +++++++++++++++++-- .../dialogs/ConnectCastingDialog.kt | 4 +- app/src/main/res/values/strings.xml | 2 + 7 files changed, 64 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt index 52d8a663..00f47885 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt @@ -216,9 +216,14 @@ private fun ByteArray.toInetAddress(): InetAddress { return InetAddress.getByAddress(this); } -fun getConnectedSocket(addresses: List, port: Int): Socket? { +fun getConnectedSocket(attemptAddresses: List, port: Int): Socket? { val timeout = 2000 + + val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance() else attemptAddresses; + if(addresses.isEmpty()) + throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})"); + if (addresses.isEmpty()) { return null; } diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index cedc4316..2bd95905 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -583,10 +583,15 @@ class Settings : FragmentedStorageFileJson() { @Serializable(with = FlexibleBooleanSerializer::class) var keepScreenOn: Boolean = true; - @FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 1) + @FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 3) @Serializable(with = FlexibleBooleanSerializer::class) var alwaysProxyRequests: Boolean = false; + + @FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4) + @Serializable(with = FlexibleBooleanSerializer::class) + var allowIpv6: Boolean = false; + /*TODO: Should we have a different casting quality? @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) @DropdownFieldOptionsId(R.array.preferred_quality_array) diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index e84002da..8034854d 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -5,6 +5,7 @@ import android.app.AlertDialog import android.content.Context import android.content.Intent import android.graphics.Color +import android.graphics.drawable.Animatable import android.net.Uri import android.text.Layout import android.text.method.ScrollingMovementMethod @@ -199,16 +200,21 @@ class UIDialogs { dialog.show(); } - fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action) { + fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog { + return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions); + } + fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog { val builder = AlertDialog.Builder(context); val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null); builder.setView(view); - + builder.setCancelable(defaultCloseAction > -2); val dialog = builder.create(); registerDialogOpened(dialog); view.findViewById(R.id.dialog_icon).apply { this.setImageResource(icon); + if(animated) + this.drawable.assume { it.start() }; } view.findViewById(R.id.dialog_text).apply { this.text = text; @@ -275,6 +281,7 @@ class UIDialogs { registerDialogClosed(dialog); } dialog.show(); + return dialog; } fun showGeneralErrorDialog(context: Context, msg: String, ex: Throwable? = null, button: String = "Ok", onOk: (()->Unit)? = null) { diff --git a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt index 9e12f78c..85b928c2 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt @@ -3,6 +3,7 @@ package com.futo.platformplayer.casting import android.os.Looper import android.util.Base64 import android.util.Log +import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.casting.models.FCastDecryptedMessage import com.futo.platformplayer.casting.models.FCastEncryptedMessage @@ -32,6 +33,7 @@ import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.math.BigInteger +import java.net.Inet4Address import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index e329a495..90177050 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.casting +import android.app.AlertDialog import android.content.ContentResolver import android.content.Context import android.net.Uri @@ -9,6 +10,7 @@ import android.util.Log import android.util.Xml import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi +import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.http.ManagedHttpClient @@ -239,6 +241,9 @@ class StateCasting { Logger.i(TAG, "CastingService stopped.") } + private val _castingDialogLock = Any(); + private var _currentDialog: AlertDialog? = null; + @Synchronized fun connectDevice(device: CastingDevice) { if (activeDevice == device) @@ -272,10 +277,39 @@ class StateCasting { invokeInMainScopeIfRequired { StateApp.withContext(false) { context -> context.let { + Logger.i(TAG, "Casting state changed to ${castConnectionState}"); when (castConnectionState) { - CastConnectionState.CONNECTED -> UIDialogs.toast(it, "Connected to device") - CastConnectionState.CONNECTING -> UIDialogs.toast(it, "Connecting to device...") - CastConnectionState.DISCONNECTED -> UIDialogs.toast(it, "Disconnected from device") + CastConnectionState.CONNECTED -> { + Logger.i(TAG, "Casting connected to [${device.name}]"); + UIDialogs.appToast("Connected to device") + synchronized(_castingDialogLock) { + if(_currentDialog != null) { + _currentDialog?.hide(); + _currentDialog = null; + } + } + } + CastConnectionState.CONNECTING -> { + Logger.i(TAG, "Casting connecting to [${device.name}]"); + UIDialogs.toast(it, "Connecting to device...") + synchronized(_castingDialogLock) { + if(_currentDialog == null) { + _currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true, "Connecting to [${device.name}]", "Make sure you are on the same network", null, -2, + UIDialogs.Action("Disconnect", { + device.stop(); + })); + } + } + } + CastConnectionState.DISCONNECTED -> { + UIDialogs.toast(it, "Disconnected from device") + synchronized(_castingDialogLock) { + if(_currentDialog != null) { + _currentDialog?.hide(); + _currentDialog = null; + } + } + } } } }; diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt index bd5da2ea..8f3b836c 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -73,11 +73,11 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { }; _rememberedAdapter.onConnect.subscribe { _ -> dismiss() - UIDialogs.showCastingDialog(context) + //UIDialogs.showCastingDialog(context) } _adapter.onConnect.subscribe { _ -> dismiss() - UIDialogs.showCastingDialog(context) + //UIDialogs.showCastingDialog(context) } _recyclerRememberedDevices.adapter = _rememberedAdapter; _recyclerRememberedDevices.layoutManager = LinearLayoutManager(context); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af450660..e7c101c9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -72,6 +72,8 @@ Keep screen on while casting Always proxy requests Always proxy requests when casting data through the device. + Allow IPV6 + If casting over IPV6 is allowed, can cause issues on some networks Discover Find new video sources to add These sources have been disabled