diff --git a/app/build.gradle b/app/build.gradle index 5f38c749..4f1bd42b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -160,6 +160,9 @@ android { dependencies { implementation 'com.google.dagger:dagger:2.48' implementation 'androidx.test:monitor:1.7.2' + implementation 'androidx.compose.material:material-icons-extended:1.7.8' + implementation 'com.github.bumptech.glide:compose:1.0.0-beta01' + implementation 'androidx.constraintlayout:constraintlayout-compose:1.1.1' annotationProcessor 'com.google.dagger:dagger-compiler:2.48' //Core diff --git a/app/src/main/assets/scripts/source.js b/app/src/main/assets/scripts/source.js index b6b4ab6d..1a30c006 100644 --- a/app/src/main/assets/scripts/source.js +++ b/app/src/main/assets/scripts/source.js @@ -743,6 +743,7 @@ let plugin = { //To override by plugin const source = { getHome() { return new ContentPager([], false, {}); }, + getShorts() { return new VideoPager([], false, {}); }, enable(config){ }, disable() {}, diff --git a/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt index 590ecc32..156bd209 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt @@ -12,6 +12,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails +import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.models.ImageVariable @@ -35,6 +36,11 @@ interface IPlatformClient { */ fun getHome(): IPager + /** + * Gets the shorts feed + */ + fun getShorts(): IPager + //Search /** * Gets search suggestion for the provided query string diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt index 2b6deaf8..1c08bc83 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt @@ -22,6 +22,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails +import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.platforms.js.internal.JSCallDocs import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs import com.futo.platformplayer.api.media.platforms.js.internal.JSDocsParameter @@ -41,6 +42,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager import com.futo.platformplayer.api.media.platforms.js.models.JSPlaybackTracker import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistDetails import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistPager +import com.futo.platformplayer.api.media.platforms.js.models.JSVideoPager import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event1 @@ -116,6 +118,7 @@ open class JSClient : IPlatformClient { val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true + val enableInShorts get() = descriptor.appSettings.tabEnabled.enableShorts ?: true fun getSubscriptionRateLimit(): Int? { val pluginRateLimit = config.subscriptionRateLimit; @@ -288,6 +291,13 @@ open class JSClient : IPlatformClient { plugin.executeTyped("source.getHome()")); } + @JSDocs(2, "source.getShorts()", "Gets the Shorts feed of the platform") + override fun getShorts(): IPager = isBusyWith("getShorts") { + ensureEnabled() + return@isBusyWith JSVideoPager(config, this, + plugin.executeTyped("source.getShorts()")) + } + @JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query") @JSDocsParameter("query", "Query to complete suggestions for") override fun searchSuggestions(query: String): Array = isBusyWith("searchSuggestions") { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt index a637e89d..ff67e56e 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt @@ -47,6 +47,7 @@ class SourcePluginConfig( var subscriptionRateLimit: Int? = null, var enableInSearch: Boolean = true, var enableInHome: Boolean = true, + var enableInShorts: Boolean = true, var supportedClaimTypes: List = listOf(), var primaryClaimFieldType: Int? = null, var developerSubmitUrl: String? = null, diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt index add53131..971c7baa 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt @@ -103,9 +103,11 @@ class SourcePluginDescriptor { @FormField(R.string.home, FieldForm.TOGGLE, R.string.show_content_in_home_tab, 1) var enableHome: Boolean? = null; - @FormField(R.string.search, FieldForm.TOGGLE, R.string.show_content_in_search_results, 2) var enableSearch: Boolean? = null; + + @FormField(R.string.shorts, FieldForm.TOGGLE, R.string.show_content_in_shorts_tab, 3) + var enableShorts: Boolean? = null; } @FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 3) @@ -143,6 +145,8 @@ class SourcePluginDescriptor { tabEnabled.enableHome = config.enableInHome if(tabEnabled.enableSearch == null) tabEnabled.enableSearch = config.enableInSearch + if(tabEnabled.enableShorts == null) + tabEnabled.enableShorts = config.enableInShorts } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt index e86c9ba6..66554572 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt @@ -7,12 +7,12 @@ import java.util.stream.IntStream * A Content MultiPager that returns results based on a specified distribution * TODO: Merge all basic distribution pagers */ -class MultiDistributionContentPager : MultiPager { +class MultiDistributionContentPager : MultiPager { - private val dist : HashMap, Float>; - private val distConsumed : HashMap, Float>; + private val dist : HashMap, Float>; + private val distConsumed : HashMap, Float>; - constructor(pagers : Map, Float>) : super(pagers.keys.toMutableList()) { + constructor(pagers : Map, Float>) : super(pagers.keys.toMutableList()) { val distTotal = pagers.values.sum(); dist = HashMap(); @@ -25,7 +25,7 @@ class MultiDistributionContentPager : MultiPager { } @Synchronized - override fun selectItemIndex(options: Array>): Int { + override fun selectItemIndex(options: Array>): Int { if(options.size == 0) return -1; var bestIndex = 0; @@ -42,6 +42,4 @@ class MultiDistributionContentPager : MultiPager { distConsumed[options[bestIndex].pager.getPager()] = bestConsumed; return bestIndex; } - - } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt index 907ca8db..2a9f954f 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt @@ -2,18 +2,58 @@ package com.futo.platformplayer.fragment.mainactivity.main import android.app.Dialog import android.content.Context +import android.content.DialogInterface +import android.content.Intent import android.graphics.Bitmap import android.graphics.drawable.Animatable import android.graphics.drawable.Drawable +import android.net.Uri import android.os.Bundle +import android.text.Spanned import android.util.AttributeSet +import android.util.TypedValue +import android.view.ContextThemeWrapper import android.view.LayoutInflater import android.view.SoundEffectConstants +import android.view.View +import android.view.ViewGroup +import android.widget.Button import android.widget.FrameLayout import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Comment +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.ThumbDown +import androidx.compose.material.icons.filled.ThumbDownOffAlt import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material.icons.filled.ThumbUpOffAlt +import androidx.compose.material.icons.outlined.Share import androidx.compose.material.icons.outlined.ThumbUp +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.ripple.RippleAlpha import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -22,20 +62,40 @@ import androidx.compose.material3.IconToggleButton import androidx.compose.material3.LocalRippleConfiguration import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RippleConfiguration +import androidx.compose.material3.Text import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.graphics.drawable.toDrawable +import androidx.core.view.marginBottom import androidx.lifecycle.lifecycleScope +import androidx.media3.common.C +import androidx.media3.common.Format import androidx.media3.common.util.UnstableApi import com.bumptech.glide.Glide +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition import com.futo.platformplayer.R @@ -43,54 +103,124 @@ import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException +import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink +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.api.media.models.streams.VideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource +import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.LocalSubtitleSource +import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.casting.CastConnectionState +import com.futo.platformplayer.casting.StateCasting +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event3 import com.futo.platformplayer.downloads.VideoLocal +import com.futo.platformplayer.dp import com.futo.platformplayer.engine.exceptions.ScriptAgeException import com.futo.platformplayer.engine.exceptions.ScriptException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException import com.futo.platformplayer.exceptions.UnsupportedCastException +import com.futo.platformplayer.fixHtmlLinks +import com.futo.platformplayer.getNowDiffSeconds +import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlugins +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.toHumanBitrate +import com.futo.platformplayer.toHumanBytesSize +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.views.MonetizationView +import com.futo.platformplayer.views.comments.AddCommentView +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.overlays.DescriptionOverlay +import com.futo.platformplayer.views.overlays.RepliesOverlay +import com.futo.platformplayer.views.overlays.SupportOverlay +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTitle +import com.futo.platformplayer.views.pills.OnLikeDislikeUpdatedArgs +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.segments.CommentsList import com.futo.platformplayer.views.video.FutoShortPlayer +import com.futo.platformplayer.views.video.FutoVideoPlayerBase +import com.futo.polycentric.core.ApiMethods +import com.futo.polycentric.core.ContentType +import com.futo.polycentric.core.Models +import com.futo.polycentric.core.Opinion +import com.futo.polycentric.core.PolycentricProfile +import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions +import com.futo.polycentric.core.toURLInfoSystemLinkUrl +import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.theme.overlay.MaterialThemeOverlay +import com.google.protobuf.ByteString import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import userpackage.Protocol import java.time.OffsetDateTime +import kotlin.contracts.Effect import kotlin.coroutines.cancellation.CancellationException @OptIn(ExperimentalMaterial3Api::class) @UnstableApi class ShortView : ConstraintLayout { - private var mainFragment: MainFragment? = null + private lateinit var mainFragment: MainFragment private val player: FutoShortPlayer private val overlayLoading: FrameLayout private val overlayLoadingSpinner: ImageView + private lateinit var overlayQualityContainer: FrameLayout + + private var overlayQualitySelector: SlideUpMenuOverlay? = null - private var url: String? = null private var video: IPlatformVideo? = null + set(value) { + field = value + onVideoUpdated.emit(value) + } private var videoDetails: IPlatformVideoDetails? = null private var playWhenReady = false +// private var playerAttached = false + private var _lastVideoSource: IVideoSource? = null private var _lastAudioSource: IAudioSource? = null private var _lastSubtitleSource: ISubtitleSource? = null private var loadVideoJob: Job? = null + private var loadLikesJob: Job? = null + + val onResetTriggered = Event0() + val onPlayingToggled = Event1() + val onLikesLoaded = Event3() + val onLikeDislikeUpdated = Event1() + val onVideoUpdated = Event1() private val bottomSheet: CommentsModalBottomSheet = CommentsModalBottomSheet() @@ -101,7 +231,7 @@ class ShortView : ConstraintLayout { overlayLoading = findViewById(R.id.short_view_loading_overlay) overlayLoadingSpinner = findViewById(R.id.short_view_loader) - setupComposeView() + init() } // Required constructor for XML inflation with attributes @@ -111,7 +241,7 @@ class ShortView : ConstraintLayout { overlayLoading = findViewById(R.id.short_view_loading_overlay) overlayLoadingSpinner = findViewById(R.id.short_view_loader) - setupComposeView() + init() } // Required constructor for XML inflation with attributes and style @@ -121,34 +251,315 @@ class ShortView : ConstraintLayout { overlayLoading = findViewById(R.id.short_view_loading_overlay) overlayLoadingSpinner = findViewById(R.id.short_view_loader) - setupComposeView() + init() } - constructor(inflater: LayoutInflater, fragment: MainFragment) : super(inflater.context) { + constructor(inflater: LayoutInflater, fragment: MainFragment, overlayQualityContainer: FrameLayout) : super(inflater.context) { inflater.inflate(R.layout.view_short, this, true) player = findViewById(R.id.short_player) overlayLoading = findViewById(R.id.short_view_loading_overlay) overlayLoadingSpinner = findViewById(R.id.short_view_loader) - - setupComposeView() + this.overlayQualityContainer = overlayQualityContainer layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT ) this.mainFragment = fragment + bottomSheet.mainFragment = fragment + + init() } - private fun setupComposeView () { + private fun init() { + player.setOnClickListener { + if (player.activelyPlaying) { + player.pause() + onPlayingToggled.emit(false) + } else { + player.play() + onPlayingToggled.emit(true) + } + } + + setupComposeView() + } + + @androidx.annotation.OptIn(UnstableApi::class) + private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, videoLocal: VideoLocal? = null, liveStreamVideoFormats: List? = null, liveStreamAudioFormats: List? = null) { + Logger.i(TAG, "updateQualitySourcesOverlay"); + + val video: IPlatformVideoDetails?; + val localVideoSources: List?; + val localAudioSource: List?; + val localSubtitleSources: List?; + + val videoSources: List?; + val audioSources: List?; + + if (videoDetails is VideoLocal) { + video = videoLocal?.videoSerialized; + localVideoSources = videoDetails.videoSource.toList(); + localAudioSource = videoDetails.audioSource.toList(); + localSubtitleSources = videoDetails.subtitlesSources.toList(); + videoSources = null + audioSources = null; + } else { + video = videoDetails; + videoSources = video?.video?.videoSources?.toList(); + audioSources = + if (video?.video?.isUnMuxed == true) (video.video as VideoUnMuxedSourceDescriptor).audioSources.toList() + else null + if (videoLocal != null) { + localVideoSources = videoLocal.videoSource.toList(); + localAudioSource = videoLocal.audioSource.toList(); + localSubtitleSources = videoLocal.subtitlesSources.toList(); + } else { + localVideoSources = null; + localAudioSource = null; + localSubtitleSources = null; + } + } + + val doDedup = Settings.instance.playback.simplifySources; + + val bestVideoSources = if (doDedup) (videoSources?.map { it.height * it.width }?.distinct() + ?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) } + ?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))?.distinct() + ?.filterNotNull()?.toList() ?: listOf() else videoSources?.toList() ?: listOf() + val bestAudioContainer = + audioSources?.let { VideoHelper.selectBestAudioSource(it, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS)?.container }; + val bestAudioSources = + if (doDedup) audioSources?.filter { it.container == bestAudioContainer } + ?.plus(audioSources.filter { it is IHLSManifestAudioSource || it is IDashManifestSource }) + ?.distinct()?.toList() ?: listOf() else audioSources?.toList() ?: listOf(); + + val canSetSpeed = true + val currentPlaybackRate = player.getPlaybackRate() + overlayQualitySelector = + SlideUpMenuOverlay(this.context, overlayQualityContainer, context.getString( + R.string.quality + ), null, true, if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate)) } else null, if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply { + setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), currentPlaybackRate!!.toString()); + onClick.subscribe { v -> + + player.setPlaybackRate(v.toFloat()); + setSelected(v); + + }; + } else null, + + if (localVideoSources?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.offline_video), "video", *localVideoSources.map { + SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", tag = it, call = { handleSelectVideoTrack(it) }); + }.toList().toTypedArray() + ) + else null, if (localAudioSource?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.offline_audio), "audio", *localAudioSource.map { + SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), tag = it, call = { handleSelectAudioTrack(it) }); + }.toList().toTypedArray() + ) + else null, if (localSubtitleSources?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.offline_subtitles), "subtitles", *localSubtitleSources.map { + SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (liveStreamVideoFormats?.isEmpty() == false) SlideUpMenuGroup( + this.context, context.getString(R.string.stream_video), "video", (listOf( + SlideUpMenuItem(this.context, R.drawable.ic_movie, "Auto", tag = "auto", call = { player.selectVideoTrack(-1) }) + ) + (liveStreamVideoFormats.map { + SlideUpMenuItem(this.context, R.drawable.ic_movie, it.label + ?: it.containerMimeType + ?: it.bitrate.toString(), "${it.width}x${it.height}", tag = it, call = { player.selectVideoTrack(it.height) }); + })) + ) + else null, if (liveStreamAudioFormats?.isEmpty() == false) SlideUpMenuGroup( + this.context, context.getString(R.string.stream_audio), "audio", *liveStreamAudioFormats.map { + SlideUpMenuItem(this.context, R.drawable.ic_music, "${it.label ?: it.containerMimeType} ${it.bitrate}", "", tag = it, call = { player.selectAudioTrack(it.bitrate) }); + }.toList().toTypedArray() + ) + else null, + + if (bestVideoSources.isNotEmpty()) SlideUpMenuGroup( + this.context, context.getString(R.string.video), "video", *bestVideoSources.map { + val estSize = VideoHelper.estimateSourceSize(it); + val prefix = + if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; + SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectVideoTrack(it) }); + }.toList().toTypedArray() + ) + else null, if (bestAudioSources.isNotEmpty()) SlideUpMenuGroup( + this.context, context.getString(R.string.audio), "audio", *bestAudioSources.map { + val estSize = VideoHelper.estimateSourceSize(it); + val prefix = + if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; + SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectAudioTrack(it) }); + }.toList().toTypedArray() + ) + else null, if (video?.subtitles?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.subtitles), "subtitles", *video.subtitles.map { + SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) }) + }.toList().toTypedArray() + ) + else null + ); + } + + private fun handleSelectVideoTrack(videoSource: IVideoSource) { + Logger.i(TAG, "handleSelectAudioTrack(videoSource=$videoSource)") + val video = videoDetails ?: return; + + if (_lastVideoSource == videoSource) return; + + val d = StateCasting.instance.activeDevice; + if (d != null && d.connectionState == CastConnectionState.CONNECTED) StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); + else if (!player.swapSources(videoSource, _lastAudioSource, true, true, true)) player.hideControls(false); //TODO: Disable player? + + _lastVideoSource = videoSource; + } + + private fun handleSelectAudioTrack(audioSource: IAudioSource) { + Logger.i(TAG, "handleSelectAudioTrack(audioSource=$audioSource)") + val video = videoDetails ?: return; + + if (_lastAudioSource == audioSource) return; + + val d = StateCasting.instance.activeDevice; + if (d != null && d.connectionState == CastConnectionState.CONNECTED) StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); + else (!player.swapSources(_lastVideoSource, audioSource, true, true, true)) + player.hideControls(false); //TODO: Disable player? + + _lastAudioSource = audioSource; + } + + private fun handleSelectSubtitleTrack(subtitleSource: ISubtitleSource) { + Logger.i(TAG, "handleSelectSubtitleTrack(subtitleSource=$subtitleSource)") + val video = videoDetails ?: return; + + var toSet: ISubtitleSource? = subtitleSource + if (_lastSubtitleSource == subtitleSource) toSet = null; + + val d = StateCasting.instance.activeDevice; + if (d != null && d.connectionState == CastConnectionState.CONNECTED) StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); + else player.swapSubtitles(mainFragment!!.lifecycleScope, toSet); + + _lastSubtitleSource = toSet; + } + + private fun showVideoSettings() { + Logger.i(TAG, "showVideoSettings") + overlayQualitySelector?.selectOption("video", _lastVideoSource); + overlayQualitySelector?.selectOption("audio", _lastAudioSource); + overlayQualitySelector?.selectOption("subtitles", _lastSubtitleSource); + + if (_lastVideoSource is IDashManifestSource || _lastVideoSource is IHLSManifestSource) { + + val videoTracks = + player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO } + + var selectedQuality: Format? = null + + if (videoTracks != null) { + for (i in 0 until videoTracks.mediaTrackGroup.length) { + if (videoTracks.mediaTrackGroup.getFormat(i).height == player.targetTrackVideoHeight) { + selectedQuality = videoTracks.mediaTrackGroup.getFormat(i) + } + } + } + + var videoMenuGroup: SlideUpMenuGroup? = null + for (view in overlayQualitySelector!!.groupItems) { + if (view is SlideUpMenuGroup && view.groupTag == "video") { + videoMenuGroup = view + } + } + + if (selectedQuality != null) { + videoMenuGroup?.getItem("auto")?.setSubText("") + overlayQualitySelector?.selectOption("video", selectedQuality) + } else { + videoMenuGroup?.getItem("auto") + ?.setSubText("${player.exoPlayer?.player?.videoFormat?.width}x${player.exoPlayer?.player?.videoFormat?.height}") + overlayQualitySelector?.selectOption("video", "auto") + } + } + + val currentPlaybackRate = player.getPlaybackRate() ?: 1.0 + overlayQualitySelector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" } + ?.let { + (it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString()) + }; + + overlayQualitySelector?.show(); +// _slideUpOverlay = overlayQualitySelector; + } + + @OptIn(ExperimentalGlideComposeApi::class) + private fun setupComposeView() { val composeView: ComposeView = findViewById(R.id.compose_view_test_button) composeView.apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { // In Compose world MaterialTheme { - var checked by remember { mutableStateOf(false) } + var likesLoaded by remember { mutableStateOf(false) } + var likeChecked by remember { mutableStateOf(false) } + var likes by remember { mutableLongStateOf(0) } + var dislikeChecked by remember { mutableStateOf(false) } + var dislikes by remember { mutableLongStateOf(0) } + var isPlaying by remember { mutableStateOf(false) } + var showPlayPauseIcon by remember { mutableStateOf(false) } + var currentVideo by remember { mutableStateOf(video) } + + DisposableEffect(onVideoUpdated) { + val tag = "video" + onVideoUpdated.subscribe(tag) { + currentVideo = it + } + + // Cleanup listener when composable is disposed + onDispose { + onVideoUpdated.remove(tag) + } + } + + DisposableEffect(onLikesLoaded) { + val tag = "likes" + onLikesLoaded.subscribe(tag) { rating, liked, disliked -> + likes = rating.likes + dislikes = rating.dislikes + likeChecked = liked + dislikeChecked = disliked + likesLoaded = true + } + + // Cleanup listener when composable is disposed + onDispose { + onLikesLoaded.remove(tag) + } + } + + DisposableEffect(onPlayingToggled) { + val tag = "icon" + onPlayingToggled.subscribe(tag) { + isPlaying = it + showPlayPauseIcon = true + } + + // Cleanup listener when composable is disposed + onDispose { + onPlayingToggled.remove(tag) + } + } val tint = Color.White + val buttonTextStyle = TextStyle( + fontSize = 12.sp, + shadow = Shadow( + color = Color.Black, blurRadius = 3f + ) + ) + val buttonOffset = 8.dp val alpha = 0.2f val rippleConfiguration = @@ -156,44 +567,296 @@ class ShortView : ConstraintLayout { val view = LocalView.current - CompositionLocalProvider(LocalRippleConfiguration provides rippleConfiguration) { - IconToggleButton( - checked = checked, - onCheckedChange = { - checked = it - view.playSoundEffect(SoundEffectConstants.CLICK) - }, - ) { - if (checked) { - Icon( - Icons.Filled.ThumbUp, contentDescription = "Liked", tint = tint, - ) - } else { - Icon( - Icons.Outlined.ThumbUp, contentDescription = "Not Liked", tint = tint, - ) + Box { + ConstraintLayout(modifier = Modifier.fillMaxSize()) { + val (title, buttons) = createRefs() + +// val horizontalChain = createHorizontalChain(title, buttons, chainStyle = ChainStyle.SpreadInside) + Box(modifier = Modifier.constrainAs(title) { +// top.linkTo(parent.top) + bottom.linkTo(parent.bottom, margin = 16.dp) + start.linkTo(parent.start, margin = 8.dp) + end.linkTo(buttons.start) + width = Dimension.fillToConstraints + } +// .fillMaxWidth() + ) { + Column( + modifier = Modifier + .align(Alignment.BottomStart), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.clickable(onClick = { + view.playSoundEffect(SoundEffectConstants.CLICK) + mainFragment!!.navigate(currentVideo?.author) + }), + + ) { + GlideImage( + model = currentVideo?.author?.thumbnail, contentDescription = "Channel Thumbnail Image", modifier = Modifier + .size(24.dp) + .clip(CircleShape) + ) + Text( + currentVideo?.author?.name ?: "", + color = tint, + fontSize = 14.sp + ) + } + + Text( + currentVideo?.name + ?: "", color = tint, maxLines = 1, overflow = TextOverflow.Ellipsis, + fontSize = 14.sp + ) + } + } + + Box(modifier = Modifier.constrainAs(buttons) { +// top.linkTo(parent.top) + bottom.linkTo(parent.bottom, margin = 16.dp) + start.linkTo(title.end, margin = 12.dp) + end.linkTo(parent.end, margin = 4.dp) + marginBottom + + }) { + CompositionLocalProvider(LocalRippleConfiguration provides rippleConfiguration) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.align(Alignment.BottomEnd) + ) { + if (likesLoaded) { + Box { + IconToggleButton( + modifier = Modifier.padding(bottom = buttonOffset), + checked = likeChecked, + onCheckedChange = { checked -> + StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { + view.playSoundEffect(SoundEffectConstants.CLICK) + if (dislikeChecked && !likeChecked) { + dislikes-- + dislikeChecked = false + } + + if (likeChecked) { + likes-- + } else { + likes++ + } + + likeChecked = checked + onLikeDislikeUpdated.emit( + OnLikeDislikeUpdatedArgs( + it, likes, likeChecked, dislikes, dislikeChecked + ) + ) + } + }, + ) { + if (likeChecked) { + Icon( + Icons.Default.ThumbUp, contentDescription = "Liked", tint = tint, + ) + } else { + Icon( + Icons.Default.ThumbUpOffAlt, contentDescription = "Not Liked", tint = tint, + ) + } + } + Text( + likes.toString(), color = tint, + modifier = Modifier + .align(Alignment.BottomCenter) +// .offset(y = buttonOffset) + , + style = buttonTextStyle, + ) + } + Box { + IconToggleButton( + modifier = Modifier.padding(bottom = buttonOffset), + checked = dislikeChecked, + onCheckedChange = { checked -> + StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { + view.playSoundEffect(SoundEffectConstants.CLICK) + if (likeChecked && !dislikeChecked) { + likes-- + likeChecked = false + } + + if (dislikeChecked) { + dislikes-- + } else { + dislikes++ + } + + dislikeChecked = checked + onLikeDislikeUpdated.emit( + OnLikeDislikeUpdatedArgs( + it, likes, likeChecked, dislikes, dislikeChecked + ) + ) + } + }, + ) { + if (dislikeChecked) { + Icon( + Icons.Default.ThumbDown, contentDescription = "Disliked", tint = tint, + ) + } else { + Icon( + Icons.Default.ThumbDownOffAlt, contentDescription = "Not Disliked", tint = tint, + ) + } + } + Text( + dislikes.toString(), color = tint, + modifier = Modifier + .align(Alignment.BottomCenter), + style = buttonTextStyle, + ) + } + } + Box { + IconButton( + modifier = Modifier.padding(bottom = buttonOffset).align(Alignment.TopCenter), + onClick = { + view.playSoundEffect(SoundEffectConstants.CLICK) + bottomSheet.show(mainFragment!!.childFragmentManager, CommentsModalBottomSheet.TAG) + }, + ) { + Icon( + Icons.AutoMirrored.Outlined.Comment, contentDescription = "View Comments", tint = tint, + + ) + } + Text( + "Comments", color = tint, + modifier = Modifier + .align(Alignment.BottomCenter), + style = buttonTextStyle + ) + } + Box { + IconButton( + modifier = Modifier.padding(bottom = buttonOffset), + onClick = { + view.playSoundEffect(SoundEffectConstants.CLICK) + val url = currentVideo?.shareUrl ?: currentVideo?.url + mainFragment!!.startActivity(Intent.createChooser(Intent().apply { + action = Intent.ACTION_SEND; + putExtra(Intent.EXTRA_TEXT, url) + type = "text/plain" + }, null)); + }, + ) { + Icon( + Icons.Outlined.Share, contentDescription = "Share", tint = tint, + ) + } + Text( + "Share", color = tint, + modifier = Modifier + .align(Alignment.BottomCenter), + style = buttonTextStyle, + ) + } + Box { + IconButton( + modifier = Modifier.padding(bottom = buttonOffset), + onClick = { + view.playSoundEffect(SoundEffectConstants.CLICK) +// player.pause() + onResetTriggered.emit() + }, + ) { + Icon( + Icons.Default.Refresh, contentDescription = "Refresh", tint = tint, + ) + } + Text( + "Refresh", color = tint, + modifier = Modifier + .align(Alignment.BottomCenter), + style = buttonTextStyle, + ) + } + Box { + IconButton( + modifier = Modifier.padding(bottom = buttonOffset), + onClick = { + view.playSoundEffect(SoundEffectConstants.CLICK) + showVideoSettings() + }, + ) { + Icon( + Icons.Default.MoreVert, contentDescription = "Playback Options", tint = tint, + ) + } + Text( + "Quality", color = tint, + modifier = Modifier + .align(Alignment.BottomCenter), + style = buttonTextStyle, + ) + } + } + } } } + + Box( + modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center + ) { + + AnimatedVisibility( + visible = showPlayPauseIcon, enter = fadeIn(animationSpec = spring()) + scaleIn( + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium + ) + ), exit = fadeOut() + scaleOut() + ) { + Box( + modifier = Modifier + .size(94.dp) + .background(color = Color.Black.copy(alpha = 0.7f), shape = CircleShape), contentAlignment = Alignment.Center +// .padding(32.dp), // Pad the entire box + + + ) { + Icon( + imageVector = if (isPlaying) Icons.Rounded.PlayArrow + else Icons.Rounded.Pause, contentDescription = if (isPlaying) "Play" else "Pause", tint = tint, modifier = Modifier.size(64.dp) + ) + } + } + + // Auto-hide the icon after a short delay + LaunchedEffect(showPlayPauseIcon) { + if (showPlayPauseIcon) { + delay(1500) // Icon visible for 1.5 seconds + showPlayPauseIcon = false + } + } + + } } + } } } } - fun setMainFragment(fragment: MainFragment) { + fun setMainFragment(fragment: MainFragment, overlayQualityContainer: FrameLayout) { this.mainFragment = fragment + this.bottomSheet.mainFragment = fragment + this.overlayQualityContainer = overlayQualityContainer } - fun setVideo(url: String) { - if (url == this.url) { - return - } - - loadVideo(url) - } - - fun setVideo(video: IPlatformVideo) { - if (url == video.url) { + fun changeVideo(video: IPlatformVideo) { + if (this.video?.url == video.url) { return } this.video = video @@ -201,28 +864,51 @@ class ShortView : ConstraintLayout { loadVideo(video.url) } - fun setVideo(videoDetails: IPlatformVideoDetails) { - if (url == videoDetails.url) { + fun changeVideo(videoDetails: IPlatformVideoDetails) { + if (video?.url == videoDetails.url) { return } + this.video = videoDetails this.videoDetails = videoDetails } fun play() { +// if (playerAttached){ +// throw Exception() +// } +// playerAttached = true + loadLikes(this.video!!) player.attach() +// if (_lastVideoSource != null || _lastAudioSource != null) { +// if (!player.activelyPlaying) { +// player.play() +// } +// } else { playVideo() +// } + } + + // + fun pause() { + player.pause() } fun stop() { +// if (!playerAttached) { +// return +// } +// playerAttached = false playWhenReady = false player.clear() player.detach() } - fun detach() { + + fun cancel() { loadVideoJob?.cancel() + loadLikesJob?.cancel() } private fun setLoading(isLoading: Boolean) { @@ -235,8 +921,71 @@ class ShortView : ConstraintLayout { } } + private fun loadLikes(video: IPlatformVideo) { + loadLikesJob?.cancel() + loadLikesJob = CoroutineScope(Dispatchers.Main).launch { + val ref = Models.referenceFromBuffer(video.url.toByteArray()) + val extraBytesRef = + video.id.value?.let { if (it.isNotEmpty()) it.toByteArray() else null } + + mainFragment!!.lifecycleScope.launch(Dispatchers.IO) { + try { + val queryReferencesResponse = ApiMethods.getQueryReferences( + ApiMethods.SERVER, ref, null, null, arrayListOf( + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() + .setFromType(ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.like.data) + ) + .build(), Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() + .setFromType(ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.dislike.data) + ).build() + ), extraByteReferences = listOfNotNull(extraBytesRef) + ); + + val likes = queryReferencesResponse.countsList[0]; + val dislikes = queryReferencesResponse.countsList[1]; + val hasLiked = + StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/; + val hasDisliked = + StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/; + + withContext(Dispatchers.Main) { + onLikesLoaded.emit(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked) + onLikeDislikeUpdated.subscribe(this) { args -> + if (args.hasLiked) { + args.processHandle.opinion(ref, Opinion.like); + } else if (args.hasDisliked) { + args.processHandle.opinion(ref, Opinion.dislike); + } else { + args.processHandle.opinion(ref, Opinion.neutral); + } + + mainFragment!!.lifecycleScope.launch(Dispatchers.IO) { + try { + Logger.i(CommentsModalBottomSheet.Companion.TAG, "Started backfill"); + args.processHandle.fullyBackfillServersAnnounceExceptions(); + Logger.i(CommentsModalBottomSheet.Companion.TAG, "Finished backfill"); + } catch (e: Throwable) { + Logger.e(CommentsModalBottomSheet.Companion.TAG, "Failed to backfill servers", e) + } + } + + StatePolycentric.instance.updateLikeMap( + ref, args.hasLiked, args.hasDisliked + ) + }; + } + } catch (e: Throwable) { + Logger.e(CommentsModalBottomSheet.Companion.TAG, "Failed to get polycentric likes/dislikes.", e); + } + } + } + } + private fun loadVideo(url: String) { loadVideoJob?.cancel() + videoDetails = null loadVideoJob = CoroutineScope(Dispatchers.Main).launch { setLoading(true) @@ -323,6 +1072,8 @@ class ShortView : ConstraintLayout { videoDetails = result video = result + bottomSheet.video = result + setLoading(false) if (playWhenReady) playVideo() @@ -330,33 +1081,33 @@ class ShortView : ConstraintLayout { } private fun playVideo(resumePositionMs: Long = 0) { - val video = videoDetails + val videoDetails = this@ShortView.videoDetails - if (video === null) { + if (videoDetails === null) { playWhenReady = true return } - bottomSheet.show(mainFragment!!.childFragmentManager, CommentsModalBottomSheet.TAG) + updateQualitySourcesOverlay(videoDetails, null) try { val videoSource = _lastVideoSource - ?: player.getPreferredVideoSource(video, Settings.instance.playback.getCurrentPreferredQualityPixelCount()) + ?: player.getPreferredVideoSource(videoDetails, Settings.instance.playback.getCurrentPreferredQualityPixelCount()) val audioSource = _lastAudioSource - ?: player.getPreferredAudioSource(video, Settings.instance.playback.getPrimaryLanguage(context)) + ?: player.getPreferredAudioSource(videoDetails, Settings.instance.playback.getPrimaryLanguage(context)) val subtitleSource = _lastSubtitleSource - ?: (if (video is VideoLocal) video.subtitlesSources.firstOrNull() else null) + ?: (if (videoDetails is VideoLocal) videoDetails.subtitlesSources.firstOrNull() else null) Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)") if (videoSource == null && audioSource == null) { UIDialogs.showDialog( context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), context.getString(R.string.this_video_is_unavailable), null, 0, UIDialogs.Action(context.getString(R.string.close), { }, UIDialogs.ActionStyle.PRIMARY) ) - StatePlatform.instance.clearContentDetailCache(video.url) + StatePlatform.instance.clearContentDetailCache(videoDetails.url) return } - val thumbnail = video.thumbnails.getHQThumbnail() + val thumbnail = videoDetails.thumbnails.getHQThumbnail() if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap() .load(thumbnail).into(object : CustomTarget() { override fun onResourceReady(resource: Bitmap, transition: Transition?) { @@ -388,36 +1139,457 @@ class ShortView : ConstraintLayout { const val TAG = "VideoDetailView" } - class CommentsModalBottomSheet : BottomSheetDialogFragment() { + class CommentsModalBottomSheet() : BottomSheetDialogFragment() { + var mainFragment: MainFragment? = null + + private lateinit var containerContent: FrameLayout + private lateinit var containerContentMain: LinearLayout + private lateinit var containerContentReplies: RepliesOverlay + private lateinit var containerContentDescription: DescriptionOverlay + private lateinit var containerContentSupport: SupportOverlay + + private lateinit var title: TextView + private lateinit var subTitle: TextView; + private lateinit var channelName: TextView + private lateinit var channelMeta: TextView + private lateinit var creatorThumbnail: CreatorThumbnail + private lateinit var channelButton: LinearLayout; + private lateinit var monetization: MonetizationView + private lateinit var platform: PlatformIndicator; + private lateinit var textLikes: TextView; + private lateinit var textDislikes: TextView; + private lateinit var layoutRating: LinearLayout; + private lateinit var imageDislikeIcon: ImageView; + private lateinit var imageLikeIcon: ImageView; +// private lateinit var buttonSubscribe: SubscribeButton + + private lateinit var description: TextView + private lateinit var descriptionContainer: LinearLayout + private lateinit var descriptionViewMore: TextView + + private lateinit var commentsList: CommentsList + private lateinit var addCommentView: AddCommentView + + private var polycentricProfile: PolycentricProfile? = null + + private lateinit var buttonPolycentric: Button + private lateinit var buttonPlatform: Button + + private var tabIndex: Int? = null + + private var contentOverlayView: View? = null + + lateinit var video: IPlatformVideoDetails + + private lateinit var behavior: BottomSheetBehavior + +// override fun getTheme(): Int { +// return R.style.CustomBottomSheetDialog +// } + +// override fun getTheme(): Int = R.style.CustomBottomSheetTheme + +// override fun onCreateView( +// inflater: LayoutInflater, +// container: ViewGroup?, +// savedInstanceState: Bundle? +// ): View? { +// +//// container. +// +// val bottomSheetDialog = inflater.inflate(R.layout.modal_comments, container, false) + override fun onCreateDialog( savedInstanceState: Bundle?, ): Dialog { val bottomSheetDialog = BottomSheetDialog( - requireContext() +// mainFragment!!.context!!, +// MaterialThemeOverlay.wrap(requireContext()) +// ContextThemeWrapper(requireContext(), ), +// ContextThemeWrapper( requireContext(), R.style.ThemeOverlay_App_Material3_BottomSheetDialog) + requireContext(),R.style.Custom_BottomSheetDialog_Theme +// MaterialThemeOverlay.wrap(requireContext()) +// ContextThemeWrapper(requireContext(), R.style.BottomSheetDialog_Rounded) +// R.style.ThemeOverlay_App_Material3_BottomSheetDialog +// BottomSheet +// R.style.CustomBottomSheetTheme +// R.style.ThemeOverlay_Cata +// R.style.ThemeOverlay_Catalog_BottomSheetDialog_Scrollable + //com.google.android.material.R.style.Animation_Design_BottomSheetDialog ) bottomSheetDialog.setContentView(R.layout.modal_comments) - val composeView = bottomSheetDialog.findViewById(R.id.compose_view) +// bottomSheetDialog.behavior. - composeView?.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - // In Compose world - MaterialTheme { - val view = LocalView.current - IconButton(onClick = { - view.playSoundEffect(SoundEffectConstants.CLICK) - }) { - Icon( - Icons.Outlined.ThumbUp, contentDescription = "Close Bottom Sheet" - ) - } - } + behavior = bottomSheetDialog.behavior + +// val composeView = bottomSheetDialog.findViewById(R.id.compose_view) + + containerContent = bottomSheetDialog.findViewById(R.id.contentContainer)!! + containerContentMain = bottomSheetDialog.findViewById(R.id.videodetail_container_main)!! + containerContentReplies = + bottomSheetDialog.findViewById(R.id.videodetail_container_replies)!! + containerContentDescription = + bottomSheetDialog.findViewById(R.id.videodetail_container_description)!! + containerContentSupport = + bottomSheetDialog.findViewById(R.id.videodetail_container_support)!! + + title = bottomSheetDialog.findViewById(R.id.videodetail_title)!! + subTitle = bottomSheetDialog.findViewById(R.id.videodetail_meta)!! + channelName = bottomSheetDialog.findViewById(R.id.videodetail_channel_name)!! + channelMeta = bottomSheetDialog.findViewById(R.id.videodetail_channel_meta)!! + creatorThumbnail = bottomSheetDialog.findViewById(R.id.creator_thumbnail)!! + channelButton = bottomSheetDialog.findViewById(R.id.videodetail_channel_button)!! + monetization = bottomSheetDialog.findViewById(R.id.monetization)!! + platform = bottomSheetDialog.findViewById(R.id.videodetail_platform)!! + layoutRating = bottomSheetDialog.findViewById(R.id.layout_rating)!! + textDislikes = bottomSheetDialog.findViewById(R.id.text_dislikes)!! + textLikes = bottomSheetDialog.findViewById(R.id.text_likes)!! + imageLikeIcon = bottomSheetDialog.findViewById(R.id.image_like_icon)!! + imageDislikeIcon = bottomSheetDialog.findViewById(R.id.image_dislike_icon)!! + +// buttonSubscribe = bottomSheetDialog.findViewById(R.id.button_subscribe)!! + + description = bottomSheetDialog.findViewById(R.id.videodetail_description)!! + descriptionContainer = + bottomSheetDialog.findViewById(R.id.videodetail_description_container)!! + descriptionViewMore = + bottomSheetDialog.findViewById(R.id.videodetail_description_view_more)!! + + addCommentView = bottomSheetDialog.findViewById(R.id.add_comment_view)!! + commentsList = bottomSheetDialog.findViewById(R.id.comments_list)!! + buttonPolycentric = bottomSheetDialog.findViewById(R.id.button_polycentric)!! + buttonPlatform = bottomSheetDialog.findViewById(R.id.button_platform)!! + + commentsList.onAuthorClick.subscribe { c -> + if (c !is PolycentricPlatformComment) { + return@subscribe; + } + + Logger.i(TAG, "onAuthorClick: " + c.author.id.value); + if (c.author.id.value?.startsWith("polycentric://") ?: false) { + val navUrl = + "https://harbor.social/" + c.author.id.value?.substring("polycentric://".length); + //val navUrl = "https://polycentric.io/user/" + c.author.id.value?.substring("polycentric://".length); + mainFragment!!.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl))) + //_container_content_browser.goto(navUrl); + //switchContentView(_container_content_browser); } } + commentsList.onRepliesClick.subscribe { c -> + val replyCount = c.replyCount ?: 0; + var metadata = ""; + if (replyCount > 0) { + metadata += "$replyCount " + requireContext().getString(R.string.replies); + } + + if (c is PolycentricPlatformComment) { + var parentComment: PolycentricPlatformComment = c; + containerContentReplies.load(tabIndex!! != 0, metadata, c.contextUrl, c.reference, c, { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, { + val newComment = parentComment.cloneWithUpdatedReplyCount( + (parentComment.replyCount ?: 0) + 1 + ); + commentsList.replaceComment(parentComment, newComment); + parentComment = newComment; + }); + } else { + containerContentReplies.load(tabIndex!! != 0, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) }); + } + animateOpenOverlayView(containerContentReplies); + } + + if (StatePolycentric.instance.enabled) { + buttonPolycentric.setOnClickListener { + setTabIndex(0); + StateMeta.instance.setLastCommentSection(0); + } + } else { + buttonPolycentric.visibility = GONE + } + + buttonPlatform.setOnClickListener { + setTabIndex(1) + StateMeta.instance.setLastCommentSection(1); + } + + val ref = Models.referenceFromBuffer(video.url.toByteArray()) + addCommentView.setContext(video.url, ref) + + if (Settings.instance.comments.recommendationsDefault && !Settings.instance.comments.hideRecommendations) { + setTabIndex(2, true) + } else { + when (Settings.instance.comments.defaultCommentSection) { + 0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex(0, true) else setTabIndex(1, true) + 1 -> setTabIndex(1, true) + 2 -> setTabIndex(StateMeta.instance.getLastCommentSection(), true) + } + } + +// val layoutTop: LinearLayout = bottomSheetDialog.findViewById(R.id.layout_top)!! +// containerContentMain.removeView(layoutTop) +// commentsList.setPrependedView(layoutTop) + + containerContentDescription.onClose.subscribe { animateCloseOverlayView() } + containerContentReplies.onClose.subscribe { animateCloseOverlayView() } + + descriptionViewMore.setOnClickListener { + animateOpenOverlayView(containerContentDescription) + } + + updateDescriptionUI(video.description.fixHtmlLinks()) + + val dp5 = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics) + val dp2 = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics) + + //UI + title.text = video.name; + channelName.text = video.author.name; + if (video.author.subscribers != null) { + channelMeta.text = if ((video.author.subscribers + ?: 0) > 0 + ) video.author.subscribers!!.toHumanNumber() + " " + requireContext().getString(R.string.subscribers) else ""; + (channelName.layoutParams as MarginLayoutParams).setMargins( + 0, (dp5 * -1).toInt(), 0, 0 + ); + } else { + channelMeta.text = ""; + (channelName.layoutParams as MarginLayoutParams).setMargins(0, (dp2).toInt(), 0, 0); + } + + + video.author.let { + if (it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty()) monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl); + else monetization.setPlatformMembership(null, null); + } + + val subTitleSegments: ArrayList = ArrayList(); + if (video.viewCount > 0) subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if (video.isLive) requireContext().getString(R.string.watching_now) else requireContext().getString(R.string.views)}"); + if (video.datetime != null) { + val diff = video.datetime?.getNowDiffSeconds() ?: 0; + val ago = video.datetime?.toHumanNowDiffString(true) + if (diff >= 0) subTitleSegments.add("${ago} ago"); + else subTitleSegments.add("available in ${ago}"); + } + + platform.setPlatformFromClientID(video.id.pluginId); + subTitle.text = subTitleSegments.joinToString(" • "); + creatorThumbnail.setThumbnail(video.author.thumbnail, false); + + setPolycentricProfile(null, animate = false); + + when (video.rating) { + is RatingLikeDislikes -> { + val r = video.rating as RatingLikeDislikes; + layoutRating.visibility = View.VISIBLE; + + textLikes.visibility = View.VISIBLE; + imageLikeIcon.visibility = View.VISIBLE; + textLikes.text = r.likes.toHumanNumber(); + + imageDislikeIcon.visibility = View.VISIBLE; + textDislikes.visibility = View.VISIBLE; + textDislikes.text = r.dislikes.toHumanNumber(); + } + + is RatingLikes -> { + val r = video.rating as RatingLikes; + layoutRating.visibility = View.VISIBLE; + + textLikes.visibility = View.VISIBLE; + imageLikeIcon.visibility = View.VISIBLE; + textLikes.text = r.likes.toHumanNumber(); + + imageDislikeIcon.visibility = View.GONE; + textDislikes.visibility = View.GONE; + } + + else -> { + layoutRating.visibility = View.GONE; + } + } + + monetization.onSupportTap.subscribe { + containerContentSupport.setPolycentricProfile(polycentricProfile); + animateOpenOverlayView(containerContentSupport) + }; + + monetization.onStoreTap.subscribe { + polycentricProfile?.systemState?.store?.let { + try { + val uri = Uri.parse(it); + val intent = Intent(Intent.ACTION_VIEW); + intent.data = uri; + requireContext().startActivity(intent); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to open URI: '${it}'.", e); + } + } + }; + monetization.onUrlTap.subscribe { + mainFragment!!.navigate(it) + } + + addCommentView.onCommentAdded.subscribe { + commentsList.addComment(it) + } + + channelButton.setOnClickListener { + mainFragment!!.navigate(video.author); + }; + +// composeView?.apply { +// setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) +// setContent { +// // In Compose world +// MaterialTheme { +// val view = LocalView.current +// IconButton(onClick = { +// view.playSoundEffect(SoundEffectConstants.CLICK) +// }) { +// Icon( +// Icons.Outlined.ThumbUp, contentDescription = "Close Bottom Sheet" +// ) +// } +// } +// } +// } return bottomSheetDialog } + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + animateCloseOverlayView() + } + + private fun setPolycentricProfile(profile: PolycentricProfile?, animate: Boolean) { + polycentricProfile = profile + + val dp_35 = 35.dp(requireContext().resources) + val avatar = profile?.systemState?.avatar?.selectBestImage(dp_35 * dp_35) + ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) } + + if (avatar != null) { + creatorThumbnail.setThumbnail(avatar, animate); + } else { + creatorThumbnail.setThumbnail(video?.author?.thumbnail, animate); + creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto()); + } + + val username = profile?.systemState?.username + if (username != null) { + channelName.text = username + } + + monetization.setPolycentricProfile(profile); + } + + private fun setTabIndex(index: Int?, forceReload: Boolean = false) { + Logger.i(TAG, "setTabIndex (index: ${index}, forceReload: ${forceReload})") + val changed = tabIndex != index || forceReload + if (!changed) { + return + } + + tabIndex = index +// _buttonRecommended.setTextColor(resources.getColor(if (index == 2) R.color.white else R.color.gray_ac)) + buttonPlatform.setTextColor(resources.getColor(if (index == 1) R.color.white else R.color.gray_ac)) + buttonPolycentric.setTextColor(resources.getColor(if (index == 0) R.color.white else R.color.gray_ac)) +// layoutRecommended.removeAllViews() + + if (index == null) { + addCommentView.visibility = View.GONE + commentsList.clear() +// _layoutRecommended.visibility = View.GONE + } else if (index == 0) { + addCommentView.visibility = View.VISIBLE +// _layoutRecommended.visibility = View.GONE + fetchPolycentricComments() + } else if (index == 1) { + addCommentView.visibility = View.GONE +// _layoutRecommended.visibility = View.GONE + fetchComments() + } +// else if (index == 2) { +// _addCommentView.visibility = View.GONE +// _layoutRecommended.visibility = View.VISIBLE +// _commentsList.clear() +// +// _layoutRecommended.addView(LoaderView(context).apply { +// layoutParams = LinearLayout.LayoutParams(60.dp(resources), 60.dp(resources)) +// start() +// }) +// _taskLoadRecommendations.run(null) +// } + } + + private fun fetchComments() { + Logger.i(TAG, "fetchComments") + video.let { + commentsList.load(true) { StatePlatform.instance.getComments(it) } + } + } + + private fun fetchPolycentricComments() { + Logger.i(TAG, "fetchPolycentricComments") + val video = video; + val idValue = video?.id?.value + if (video?.url?.isEmpty() != false) { + Logger.w(TAG, "Failed to fetch polycentric comments because url was null") + commentsList.clear() + return + } + + val ref = Models.referenceFromBuffer(video.url.toByteArray()) + val extraBytesRef = idValue?.let { if (it.isNotEmpty()) it.toByteArray() else null } + commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, ref, listOfNotNull(extraBytesRef)); }; + } + + private fun updateDescriptionUI(text: Spanned) { + containerContentDescription.load(text) + description.text = text + + if (description.text.isNotEmpty()) descriptionContainer.visibility = VISIBLE + else descriptionContainer.visibility = GONE + } + + private fun animateOpenOverlayView(view: View) { + if (contentOverlayView != null) { + Logger.e(TAG, "Content overlay already open") + return + } + + behavior.isDraggable = false + behavior.state = BottomSheetBehavior.STATE_EXPANDED + + val animHeight = containerContentMain.height + + view.translationY = animHeight.toFloat() + view.visibility = VISIBLE + + view.animate().setDuration(300).translationY(0f).withEndAction { + contentOverlayView = view + }.start() + } + + private fun animateCloseOverlayView() { + val curView = contentOverlayView + if (curView == null) { + Logger.e(TAG, "No content overlay open") + return + } + + behavior.isDraggable = true + + val animHeight = contentOverlayView!!.height + + curView.animate().setDuration(300).translationY(animHeight.toFloat()).withEndAction { + curView.visibility = GONE + contentOverlayView = null + }.start() + } + companion object { const val TAG = "ModalBottomSheet" } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt index b11a30a4..80322e5e 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt @@ -1,15 +1,30 @@ package com.futo.platformplayer.fragment.mainactivity.main +import android.annotation.SuppressLint +import android.graphics.drawable.Animatable import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView import androidx.annotation.OptIn -import androidx.core.view.get import androidx.media3.common.util.UnstableApi import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.states.StatePlatform +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException @UnstableApi class ShortsFragment : MainFragment() { @@ -17,14 +32,25 @@ class ShortsFragment : MainFragment() { override val isTab: Boolean = true override val hasBottomBar: Boolean get() = true - private var previousShownView: ShortView? = null + private var loadPagerJob: Job? = null + private var nextPageJob: Job? = null - private lateinit var viewPager: ViewPager2 + private var shortsPager: IPager? = null + private val videos: MutableList = mutableListOf() + + private var viewPager: ViewPager2? = null + private lateinit var overlayLoading: FrameLayout + private lateinit var overlayLoadingSpinner: ImageView + private lateinit var overlayQualityContainer: FrameLayout private lateinit var customViewAdapter: CustomViewAdapter private val urls = listOf( - "https://youtube.com/shorts/fHU6dfPHT-o?si=TVCYnt_mvAxWYACZ", "https://youtube.com/shorts/j9LQ0c4MyGk?si=FVlr90UD42y1ZIO0", "https://youtube.com/shorts/Q8LndW9YZvQ?si=mDrSsm-3Uq7IEXAT", "https://youtube.com/shorts/OIS5qHDOOzs?si=RGYeaAH9M-TRuZSr", "https://youtube.com/shorts/1Cp6EbLWVnI?si=N4QqytC48CTnfJra", "https://youtube.com/shorts/fHU6dfPHT-o?si=TVCYnt_mvAxWYACZ", "https://youtube.com/shorts/j9LQ0c4MyGk?si=FVlr90UD42y1ZIO0", "https://youtube.com/shorts/Q8LndW9YZvQ?si=mDrSsm-3Uq7IEXAT", "https://youtube.com/shorts/OIS5qHDOOzs?si=RGYeaAH9M-TRuZSr", "https://youtube.com/shorts/1Cp6EbLWVnI?si=N4QqytC48CTnfJra" + "https://youtube.com/shorts/fHU6dfPHT-o?si=TVCYnt_mvAxWYACZ", "https://youtube.com/shorts/j9LQ0c4MyGk?si=FVlr90UD42y1ZIO0", "https://youtube.com/shorts/Q8LndW9YZvQ?si=mDrSsm-3Uq7IEXAT", "https://www.youtube.com/watch?v=MXHSS-7XcBc", "https://youtube.com/shorts/OIS5qHDOOzs?si=RGYeaAH9M-TRuZSr", "https://youtube.com/shorts/1Cp6EbLWVnI?si=N4QqytC48CTnfJra", "https://youtube.com/shorts/fHU6dfPHT-o?si=TVCYnt_mvAxWYACZ", "https://youtube.com/shorts/j9LQ0c4MyGk?si=FVlr90UD42y1ZIO0", "https://youtube.com/shorts/Q8LndW9YZvQ?si=mDrSsm-3Uq7IEXAT", "https://youtube.com/shorts/OIS5qHDOOzs?si=RGYeaAH9M-TRuZSr", "https://youtube.com/shorts/1Cp6EbLWVnI?si=N4QqytC48CTnfJra" ) + init { + loadPager() + } + override fun onCreateMainView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { @@ -35,31 +61,146 @@ class ShortsFragment : MainFragment() { super.onViewCreated(view, savedInstanceState) viewPager = view.findViewById(R.id.viewPager) + overlayLoading = view.findViewById(R.id.short_view_loading_overlay) + overlayLoadingSpinner = view.findViewById(R.id.short_view_loader) + overlayQualityContainer = view.findViewById(R.id.videodetail_quality_overview) - customViewAdapter = CustomViewAdapter(urls, layoutInflater, this) - viewPager.adapter = customViewAdapter + setLoading(true) - // TODO something is laggy sometimes when swiping between videos - viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { - @OptIn(UnstableApi::class) - override fun onPageSelected(position: Int) { - previousShownView?.stop() + if (loadPagerJob?.isActive == false && videos.isEmpty()) { + loadPager() + } - val focusedView = - ((viewPager[0] as RecyclerView).findViewHolderForAdapterPosition(position) as CustomViewHolder).shortView - focusedView.play() + loadPagerJob!!.invokeOnCompletion { + customViewAdapter = CustomViewAdapter(videos, layoutInflater, this@ShortsFragment, overlayQualityContainer) { + if (!shortsPager!!.hasMorePages()) { + return@CustomViewAdapter + } + nextPage() + } + customViewAdapter.onResetTriggered.subscribe { + setLoading(true) + loadPager() + loadPagerJob!!.invokeOnCompletion { + setLoading(false) + } + } + val viewPager = viewPager!! + viewPager.adapter = customViewAdapter + + // TODO something is laggy sometimes when swiping between videos + viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + @OptIn(UnstableApi::class) + override fun onPageSelected(position: Int) { + val adapter = (viewPager.adapter as CustomViewAdapter) + adapter.previousShownView?.stop() + adapter.previousShownView = null + +// viewPager.post { + val recycler = (viewPager.getChildAt(0) as RecyclerView) + val viewHolder = + recycler.findViewHolderForAdapterPosition(position) as CustomViewHolder? + + if (viewHolder == null) { + adapter.needToPlay = position + } else { + val focusedView = viewHolder.shortView + focusedView.play() + adapter.previousShownView = focusedView + } +// } + } - previousShownView = focusedView + }) + setLoading(false) + } + } + + private fun nextPage() { + nextPageJob?.cancel() + + nextPageJob = CoroutineScope(Dispatchers.Main).launch { + try { + withContext(Dispatchers.IO) { + shortsPager!!.nextPage() + } + } catch (_: CancellationException) { + return@launch } - }) + // if it's been canceled then don't update the results + if (!isActive) { + return@launch + } + val newVideos = shortsPager!!.getResults() + CoroutineScope(Dispatchers.Main).launch { + val prevCount = customViewAdapter.itemCount + videos.addAll(newVideos) + customViewAdapter.notifyItemRangeInserted(prevCount, newVideos.size) + } + } + } + + // we just completely reset the data structure so we want to tell the adapter that + @SuppressLint("NotifyDataSetChanged") + private fun loadPager() { + loadPagerJob?.cancel() + + // if the view pager exists go back to the beginning + videos.clear() + viewPager?.adapter?.notifyDataSetChanged() + viewPager?.currentItem = 0 + + loadPagerJob = CoroutineScope(Dispatchers.Main).launch { +// delay(5000) + val pager = try { + withContext(Dispatchers.IO) { + StatePlatform.instance.getShorts() +// StatePlatform.instance.getHome() + // as IPager + } + } catch (_: CancellationException) { + return@launch + } + + // if it's been canceled then don't set the video pager + if (!isActive) { + return@launch + } + + videos.clear() + videos.addAll(pager.getResults()) + shortsPager = pager + + // if the viewPager exists then trigger data changed + viewPager?.adapter?.notifyDataSetChanged() + } + } + + private fun setLoading(isLoading: Boolean) { + if (isLoading) { + (overlayLoadingSpinner.drawable as Animatable?)?.start() + overlayLoading.visibility = View.VISIBLE + } else { + overlayLoading.visibility = View.GONE + (overlayLoadingSpinner.drawable as Animatable?)?.stop() + } } override fun onPause() { super.onPause() - previousShownView?.stop() + customViewAdapter.previousShownView?.pause() + } + + override fun onResume() { + super.onResume() + } + + override fun onStop() { + super.onStop() + customViewAdapter.previousShownView?.stop() } companion object { @@ -69,26 +210,51 @@ class ShortsFragment : MainFragment() { } class CustomViewAdapter( - private val urls: List, private val inflater: LayoutInflater, private val fragment: MainFragment + private val videos: MutableList, + private val inflater: LayoutInflater, + private val fragment: MainFragment, + private val overlayQualityContainer: FrameLayout, + private val onNearEnd: () -> Unit, ) : RecyclerView.Adapter() { + val onResetTriggered = Event0() + var previousShownView: ShortView? = null + var needToPlay: Int? = null + @OptIn(UnstableApi::class) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder { - val shortView = ShortView(inflater, fragment) + val shortView = ShortView(inflater, fragment, overlayQualityContainer) + shortView.onResetTriggered.subscribe { + onResetTriggered.emit() + } return CustomViewHolder(shortView) } @OptIn(UnstableApi::class) override fun onBindViewHolder(holder: CustomViewHolder, position: Int) { - holder.shortView.setVideo(urls[position]) + holder.shortView.changeVideo(videos[position]) + + if (position == itemCount - 1) { + onNearEnd() + } } - @OptIn(UnstableApi::class) override fun onViewRecycled(holder: CustomViewHolder) { super.onViewRecycled(holder) - holder.shortView.detach() + holder.shortView.cancel() + } - override fun getItemCount(): Int = urls.size + override fun onViewAttachedToWindow(holder: CustomViewHolder) { + super.onViewAttachedToWindow(holder) + + if (holder.absoluteAdapterPosition == needToPlay) { + holder.shortView.play() + needToPlay = null + previousShownView = holder.shortView + } + } + + override fun getItemCount(): Int = videos.size } @OptIn(UnstableApi::class) diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt index dfddd51f..017e7f37 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -417,6 +417,47 @@ class StatePlatform { pager.initialize(); return pager; } + fun getShorts(): IPager { + Logger.i(TAG, "Platform - getShorts"); + var clientIdsOngoing = mutableListOf(); + val clients = getSortedEnabledClient().filter { if (it is JSClient) it.enableInShorts else true }; + + StateApp.instance.scopeOrNull?.let { + it.launch(Dispatchers.Default) { + try { + // plugins that take longer than 5 seconds to load are considered "slow" + delay(5000); + val slowClients = synchronized(clientIdsOngoing) { + return@synchronized clients.filter { clientIdsOngoing.contains(it.id) }; + }; + for(client in slowClients) + UIDialogs.toast("${client.name} is still loading..\nConsider disabling it for Home", false); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to show toast for slow source.", e) + } + } + } + + val pages = clients.parallelStream() + .map { + Logger.i(TAG, "getShorts - ${it.name}") + synchronized(clientIdsOngoing) { + clientIdsOngoing.add(it.id); + } + val shortsResult = it.fromPool(_pagerClientPool).getShorts(); + synchronized(clientIdsOngoing) { + clientIdsOngoing.remove(it.id); + } + return@map shortsResult; + } + .asSequence() + .toList() + .associateWith { 1f }; + + val pager = MultiDistributionContentPager(pages); + pager.initialize(); + return pager; + } suspend fun getHomeRefresh(scope: CoroutineScope): IPager { Logger.i(TAG, "Platform - getHome (Refresh)"); val clients = getSortedEnabledClient().filter { if (it is JSClient) it.enableInHome else true }; diff --git a/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt b/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt index 22926841..8654600e 100644 --- a/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt +++ b/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt @@ -34,7 +34,7 @@ class PlayerManager { @Synchronized fun attach(view: PlayerView, stateName: String) { - if(view != _currentView) { + if(view != _currentView || _currentView?.player == null) { _currentView?.player = null; switchState(stateName); view.player = player; diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt index 9c2b003f..8ff562b6 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt @@ -9,6 +9,7 @@ import android.util.AttributeSet import android.view.LayoutInflater import android.view.animation.LinearInterpolator import androidx.annotation.OptIn +import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.DefaultTimeBar @@ -21,6 +22,7 @@ import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.helpers.VideoHelper +import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StatePlayer @UnstableApi @@ -33,6 +35,7 @@ class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : } private var playerAttached = false +// private set; private val videoView: PlayerView private val progressBar: DefaultTimeBar @@ -64,12 +67,15 @@ class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : } if (player.isPlaying) { - if (!progressAnimator.isStarted) { + if (progressAnimator.isPaused){ + progressAnimator.resume() + } + else if (!progressAnimator.isStarted) { progressAnimator.start() } } else { if (progressAnimator.isRunning) { - progressAnimator.cancel() + progressAnimator.pause() } } } @@ -81,10 +87,12 @@ class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : videoView = findViewById(R.id.video_player) progressBar = findViewById(R.id.video_player_progress_bar) + player.player.repeatMode = Player.REPEAT_MODE_ONE + progressBar.addListener(object : TimeBar.OnScrubListener { override fun onScrubStart(timeBar: TimeBar, position: Long) { if (progressAnimator.isRunning) { - progressAnimator.cancel() + progressAnimator.pause() } } @@ -93,7 +101,7 @@ class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { if (canceled) { progressAnimator.currentPlayTime = player.player.currentPosition - progressAnimator.start() + progressAnimator.resume() return } @@ -110,9 +118,7 @@ class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : interpolator = LinearInterpolator() addUpdateListener { animation -> - val progress = animation.animatedValue as Float - val duration = animation.duration - progressBar.setPosition((progress * duration).toLong()) + progressBar.setPosition(animation.currentPlayTime) } } } @@ -169,11 +175,28 @@ class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : @OptIn(UnstableApi::class) fun setArtwork(drawable: Drawable?) { if (drawable != null) { - videoView.defaultArtwork = drawable videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_FILL + videoView.defaultArtwork = drawable } else { - videoView.defaultArtwork = null videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_OFF + videoView.defaultArtwork = null } } + + fun getPlaybackRate(): Float { + return exoPlayer?.player?.playbackParameters?.speed ?: 1.0f + } + + fun setPlaybackRate(playbackRate: Float) { + val exoPlayer = exoPlayer?.player + Logger.i(TAG, "setPlaybackRate playbackRate=$playbackRate exoPlayer=${exoPlayer}") + + val param = PlaybackParameters(playbackRate) + exoPlayer?.playbackParameters = param + } + + // TODO remove stub + fun hideControls(stub: Boolean) { + + } } diff --git a/app/src/main/res/layout/fragment_shorts.xml b/app/src/main/res/layout/fragment_shorts.xml index ac4f755d..7d6086da 100644 --- a/app/src/main/res/layout/fragment_shorts.xml +++ b/app/src/main/res/layout/fragment_shorts.xml @@ -1,6 +1,37 @@ - + android:layout_height="match_parent"> + + + + + + + + + + diff --git a/app/src/main/res/layout/modal_comments.xml b/app/src/main/res/layout/modal_comments.xml index e079a671..835b98a7 100644 --- a/app/src/main/res/layout/modal_comments.xml +++ b/app/src/main/res/layout/modal_comments.xml @@ -1,6 +1,8 @@ - @@ -10,222 +12,401 @@ android:id="@+id/drag_handle" style="@style/Widget.Material3.BottomSheet.DragHandle" android:layout_width="match_parent" - android:layout_height="wrap_content" /> - - + + android:layout_height="match_parent" + android:orientation="vertical"> - - + - + - + + - + + - + - + - + + - + - + - + - + - + + - + - + - + + + + - + + - + + - + - + - + - + - + - + + - - + + + + + + + + + + + + + + + + +