diff --git a/app/build.gradle b/app/build.gradle index 4f1bd42b..23d17cda 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -143,10 +143,6 @@ android { } buildFeatures { buildConfig true - compose true - } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.2" } sourceSets { main { @@ -160,9 +156,7 @@ 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' + implementation 'com.google.android.material:material:1.12.0' annotationProcessor 'com.google.dagger:dagger-compiler:2.48' //Core @@ -222,7 +216,6 @@ dependencies { //Database implementation("androidx.room:room-runtime:2.6.1") annotationProcessor("androidx.room:room-compiler:2.6.1") - debugImplementation 'androidx.compose.ui:ui-tooling:1.7.8' ksp("androidx.room:room-compiler:2.6.1") implementation("androidx.room:room-ktx:2.6.1") @@ -236,10 +229,4 @@ dependencies { testImplementation "org.mockito:mockito-core:5.4.0" androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - - //Compose - def composeBom = platform('androidx.compose:compose-bom:2025.02.00') - implementation composeBom - androidTestImplementation composeBom - implementation 'androidx.compose.material3:material3' } diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 647cf6d8..73c40a2f 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -325,6 +325,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragBrowser = BrowserFragment.newInstance(); + _fragShorts.onShownEvent.subscribe { + _fragVideoDetail.closeVideoDetails() + }; + //Overlays _fragVideoDetail = VideoDetailFragment.newInstance(); //Overlay Init diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt index 2b7c055a..2d13088c 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt @@ -15,6 +15,7 @@ import android.view.ViewGroup import android.widget.* import androidx.core.animation.doOnEnd import androidx.lifecycle.lifecycleScope +import androidx.media3.common.util.UnstableApi import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs @@ -375,6 +376,7 @@ class MenuBottomBarFragment : MainActivityFragment() { fun newInstance() = MenuBottomBarFragment().apply { } + @UnstableApi //Add configurable buttons here var buttonDefinitions = listOf( ButtonDefinition(0, R.drawable.ic_home, R.drawable.ic_home_filled, R.string.home, canToggle = true, { it.currentMain is HomeFragment }, { @@ -386,11 +388,11 @@ class MenuBottomBarFragment : MainActivityFragment() { it.navigate() } }), - ButtonDefinition(1, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.shorts, canToggle = true, { it.currentMain is ShortsFragment }, { it.navigate() }), - ButtonDefinition(2, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate() }), - ButtonDefinition(3, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate() }), - ButtonDefinition(4, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate() }), - ButtonDefinition(5, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate() }), + ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate() }), + ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate() }), + ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate() }), + ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate() }), + ButtonDefinition(5, R.drawable.ic_smart_display, R.drawable.ic_smart_display_filled, R.string.shorts, canToggle = true, { it.currentMain is ShortsFragment }, { it.navigate() }), ButtonDefinition(6, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate() }), ButtonDefinition(7, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate() }), ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate() }), 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 fec8130e..29c162c6 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 @@ -14,80 +14,20 @@ import android.util.TypedValue import android.view.LayoutInflater import android.view.SoundEffectConstants import android.view.View +import android.view.animation.AccelerateInterpolator +import android.view.animation.OvershootInterpolator 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.background -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.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.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 -import androidx.compose.material3.IconButton -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.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.core.net.toUri 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 @@ -165,24 +105,37 @@ 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.button.MaterialButton 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.coroutines.cancellation.CancellationException -import androidx.core.net.toUri -@OptIn(ExperimentalMaterial3Api::class) @UnstableApi -class ShortView : ConstraintLayout { +class ShortView : FrameLayout { private lateinit var mainFragment: MainFragment private val player: FutoShortPlayer + + private val channelInfo: LinearLayout + private val creatorThumbnail: CreatorThumbnail + private val channelName: TextView + private val videoTitle: TextView + + private val likeContainer: FrameLayout + private val dislikeContainer: FrameLayout + private val likeButton: MaterialButton + private val likeCount: TextView + private val dislikeButton: MaterialButton + private val dislikeCount: TextView + + private val commentsButton: MaterialButton + private val shareButton: MaterialButton + private val refreshButton: MaterialButton + private val qualityButton: MaterialButton + + private val playPauseOverlay: FrameLayout + private val playPauseIcon: ImageView + private val overlayLoading: FrameLayout private val overlayLoadingSpinner: ImageView private lateinit var overlayQualityContainer: FrameLayout @@ -202,8 +155,9 @@ class ShortView : ConstraintLayout { private var _lastAudioSource: IAudioSource? = null private var _lastSubtitleSource: ISubtitleSource? = null - private var loadVideoJob: Job? = null - private var loadLikesJob: Job? = null + private var loadVideoTask: TaskHandler? = null + private var loadLikesTask: TaskHandler>? = + null val onResetTriggered = Event0() val onPlayingToggled = Event1() @@ -213,10 +167,43 @@ class ShortView : ConstraintLayout { private val bottomSheet: CommentsModalBottomSheet = CommentsModalBottomSheet() + var likes: Long = 0 + set(value) { + field = value + likeCount.text = value.toString() + } + + var dislikes: Long = 0 + set(value) { + field = value + dislikeCount.text = value.toString() + } + // Required constructor for XML inflation constructor(context: Context) : super(context) { inflate(context, R.layout.view_short, this) player = findViewById(R.id.short_player) + + channelInfo = findViewById(R.id.channel_info) + creatorThumbnail = findViewById(R.id.creator_thumbnail) + channelName = findViewById(R.id.channel_name) + videoTitle = findViewById(R.id.video_title) + + likeContainer = findViewById(R.id.like_container) + dislikeContainer = findViewById(R.id.dislike_container) + likeButton = findViewById(R.id.like_button) + likeCount = findViewById(R.id.like_count) + dislikeButton = findViewById(R.id.dislike_button) + dislikeCount = findViewById(R.id.dislike_count) + + commentsButton = findViewById(R.id.comments_button) + shareButton = findViewById(R.id.share_button) + refreshButton = findViewById(R.id.refresh_button) + qualityButton = findViewById(R.id.quality_button) + + playPauseOverlay = findViewById(R.id.play_pause_overlay) + playPauseIcon = findViewById(R.id.play_pause_icon) + overlayLoading = findViewById(R.id.short_view_loading_overlay) overlayLoadingSpinner = findViewById(R.id.short_view_loader) @@ -227,6 +214,27 @@ class ShortView : ConstraintLayout { constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { inflate(context, R.layout.view_short, this) player = findViewById(R.id.short_player) + + channelInfo = findViewById(R.id.channel_info) + creatorThumbnail = findViewById(R.id.creator_thumbnail) + channelName = findViewById(R.id.channel_name) + videoTitle = findViewById(R.id.video_title) + + likeContainer = findViewById(R.id.like_container) + dislikeContainer = findViewById(R.id.dislike_container) + likeButton = findViewById(R.id.like_button) + likeCount = findViewById(R.id.like_count) + dislikeButton = findViewById(R.id.dislike_button) + dislikeCount = findViewById(R.id.dislike_count) + + commentsButton = findViewById(R.id.comments_button) + shareButton = findViewById(R.id.share_button) + refreshButton = findViewById(R.id.refresh_button) + qualityButton = findViewById(R.id.quality_button) + + playPauseOverlay = findViewById(R.id.play_pause_overlay) + playPauseIcon = findViewById(R.id.play_pause_icon) + overlayLoading = findViewById(R.id.short_view_loading_overlay) overlayLoadingSpinner = findViewById(R.id.short_view_loader) @@ -237,6 +245,27 @@ class ShortView : ConstraintLayout { constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { inflate(context, R.layout.view_short, this) player = findViewById(R.id.short_player) + + channelInfo = findViewById(R.id.channel_info) + creatorThumbnail = findViewById(R.id.creator_thumbnail) + channelName = findViewById(R.id.channel_name) + videoTitle = findViewById(R.id.video_title) + + likeContainer = findViewById(R.id.like_container) + dislikeContainer = findViewById(R.id.dislike_container) + likeButton = findViewById(R.id.like_button) + likeCount = findViewById(R.id.like_count) + dislikeButton = findViewById(R.id.dislike_button) + dislikeCount = findViewById(R.id.dislike_count) + + commentsButton = findViewById(R.id.comments_button) + shareButton = findViewById(R.id.share_button) + refreshButton = findViewById(R.id.refresh_button) + qualityButton = findViewById(R.id.quality_button) + + playPauseOverlay = findViewById(R.id.play_pause_overlay) + playPauseIcon = findViewById(R.id.play_pause_icon) + overlayLoading = findViewById(R.id.short_view_loading_overlay) overlayLoadingSpinner = findViewById(R.id.short_view_loader) @@ -246,12 +275,33 @@ class ShortView : ConstraintLayout { constructor(inflater: LayoutInflater, fragment: MainFragment, overlayQualityContainer: FrameLayout) : super(inflater.context) { inflater.inflate(R.layout.view_short, this, true) player = findViewById(R.id.short_player) + + channelInfo = findViewById(R.id.channel_info) + creatorThumbnail = findViewById(R.id.creator_thumbnail) + channelName = findViewById(R.id.channel_name) + videoTitle = findViewById(R.id.video_title) + + likeContainer = findViewById(R.id.like_container) + dislikeContainer = findViewById(R.id.dislike_container) + likeButton = findViewById(R.id.like_button) + likeCount = findViewById(R.id.like_count) + dislikeButton = findViewById(R.id.dislike_button) + dislikeCount = findViewById(R.id.dislike_count) + + commentsButton = findViewById(R.id.comments_button) + shareButton = findViewById(R.id.share_button) + refreshButton = findViewById(R.id.refresh_button) + qualityButton = findViewById(R.id.quality_button) + + playPauseOverlay = findViewById(R.id.play_pause_overlay) + playPauseIcon = findViewById(R.id.play_pause_icon) + overlayLoading = findViewById(R.id.short_view_loading_overlay) overlayLoadingSpinner = findViewById(R.id.short_view_loader) this.overlayQualityContainer = overlayQualityContainer - layoutParams = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT + layoutParams = LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT ) this.mainFragment = fragment @@ -271,7 +321,136 @@ class ShortView : ConstraintLayout { } } - setupComposeView() + onPlayingToggled.subscribe { playing -> + if (playing) { + playPauseIcon.setImageResource(R.drawable.ic_play) + playPauseIcon.contentDescription = context.getString(R.string.play) + } else { + playPauseIcon.setImageResource(R.drawable.ic_pause) + playPauseIcon.contentDescription = context.getString(R.string.pause) + } + showPlayPauseIcon() + } + + onVideoUpdated.subscribe { + videoTitle.text = it?.name + creatorThumbnail.setThumbnail(it?.author?.thumbnail, true) + channelName.text = it?.author?.name + } + + channelInfo.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + mainFragment.navigate(video?.author) + } + + commentsButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + if (!bottomSheet.isAdded) { + bottomSheet.show(mainFragment.childFragmentManager, CommentsModalBottomSheet.TAG) + } + } + + shareButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + val url = video?.shareUrl ?: video?.url + mainFragment.startActivity(Intent.createChooser(Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, url) + type = "text/plain" + }, null)) + } + + refreshButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + onResetTriggered.emit() + } + + qualityButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + showVideoSettings() + } + + onLikesLoaded.subscribe(tag) { rating, liked, disliked -> + likes = rating.likes + dislikes = rating.dislikes + likeButton.isChecked = liked + dislikeButton.isChecked = disliked + + dislikeContainer.visibility = VISIBLE + likeContainer.visibility = VISIBLE + + likeButton.addOnCheckedChangeListener { button, checked -> + playSoundEffect(SoundEffectConstants.CLICK) + StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { + if (checked) { + likes++ + } else { + likes-- + } + + if (dislikeButton.isChecked && checked) { + // Chain reaction + dislikeButton.isChecked = false + } else { + // No chain reaction submit changes + onLikeDislikeUpdated.emit( + OnLikeDislikeUpdatedArgs( + it, likes, likeButton.isChecked, dislikes, dislikeButton.isChecked + ) + ) + } + } + } + + dislikeButton.addOnCheckedChangeListener { button, checked -> + playSoundEffect(SoundEffectConstants.CLICK) + StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { + if (checked) { + dislikes++ + } else { + dislikes-- + } + + if (likeButton.isChecked && checked) { + // Chain reaction + likeButton.isChecked = false + } else { + // No chain reaction submit changes + onLikeDislikeUpdated.emit( + OnLikeDislikeUpdatedArgs( + it, likes, likeButton.isChecked, dislikes, dislikeButton.isChecked + ) + ) + } + } + } + } + } + + private fun showPlayPauseIcon() { + val overlay = playPauseOverlay + + overlay.alpha = 0f + overlay.scaleX = 0f + overlay.scaleY = 0f + overlay.visibility = VISIBLE + + overlay.animate().alpha(1f).scaleX(1f).scaleY(1f).setDuration(400) + .setInterpolator(OvershootInterpolator(1.2f)) + .start() + + overlay.postDelayed({ + hidePlayPauseIcon() + }, 1500) + } + + private fun hidePlayPauseIcon() { + val overlay = playPauseOverlay + + overlay.animate().alpha(0f).scaleX(0.8f).scaleY(0.8f).setDuration(300) + .setInterpolator(AccelerateInterpolator()).withEndAction { + overlay.visibility = GONE + }.start() } // TODO merge this with the updateQualitySourcesOverlay for the normal video player @@ -327,65 +506,66 @@ class ShortView : ConstraintLayout { 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 -> + 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) + 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 + } + } 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 ) } @@ -394,6 +574,8 @@ class ShortView : ConstraintLayout { if (_lastVideoSource == videoSource) return _lastVideoSource = videoSource + + playVideo(player.position) } private fun handleSelectAudioTrack(audioSource: IAudioSource) { @@ -401,6 +583,8 @@ class ShortView : ConstraintLayout { if (_lastAudioSource == audioSource) return _lastAudioSource = audioSource + + playVideo(player.position) } private fun handleSelectSubtitleTrack(subtitleSource: ISubtitleSource) { @@ -415,12 +599,37 @@ class ShortView : ConstraintLayout { private fun showVideoSettings() { Logger.i(TAG, "showVideoSettings") + + val videoSource = _lastVideoSource + + if (videoSource is IDashManifestSource || videoSource is IHLSManifestSource) { + val videoTracks = + player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO } + val audioTracks = + player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_AUDIO } + + val videoTrackFormats = mutableListOf() + val audioTrackFormats = mutableListOf() + + if (videoTracks != null) { + for (i in 0 until videoTracks.mediaTrackGroup.length) videoTrackFormats.add(videoTracks.mediaTrackGroup.getFormat(i)) + } + if (audioTracks != null) { + for (i in 0 until audioTracks.mediaTrackGroup.length) audioTrackFormats.add(audioTracks.mediaTrackGroup.getFormat(i)) + } + + updateQualitySourcesOverlay(videoDetails, null, videoTrackFormats.distinctBy { it.height } + .sortedBy { it.height }, audioTrackFormats.distinctBy { it.bitrate } + .sortedBy { it.bitrate }) + } else { + updateQualitySourcesOverlay(videoDetails, null) + } + 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 } @@ -460,342 +669,6 @@ class ShortView : ConstraintLayout { overlayQualitySelector?.show() } - @OptIn(ExperimentalGlideComposeApi::class) - private fun setupComposeView() { - val composeView: ComposeView = findViewById(R.id.shorts_overlay_content_compose_view) - composeView.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - // In Compose world - MaterialTheme { - 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 = - RippleConfiguration(color = tint, rippleAlpha = RippleAlpha(alpha, alpha, alpha, alpha)) - - val view = LocalView.current - - Box { - ConstraintLayout(modifier = Modifier.fillMaxSize()) { - val (title, buttons) = createRefs() - - Box(modifier = Modifier.constrainAs(title) { - bottom.linkTo(parent.bottom, margin = 16.dp) - start.linkTo(parent.start, margin = 8.dp) - end.linkTo(buttons.start) - width = Dimension.fillToConstraints - }) { - 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) { - 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), - 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) - if (!bottomSheet.isAdded) { - 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 - ) { - 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) - showPlayPauseIcon = false - } - } - - } - } - - } - } - } - } - @Suppress("unused") fun setMainFragment(fragment: MainFragment, overlayQualityContainer: FrameLayout) { this.mainFragment = fragment @@ -824,7 +697,9 @@ class ShortView : ConstraintLayout { fun play() { loadLikes(this.video!!) + player.clear() player.attach() + player.clear() playVideo() } @@ -839,10 +714,9 @@ class ShortView : ConstraintLayout { player.detach() } - fun cancel() { - loadVideoJob?.cancel() - loadLikesJob?.cancel() + loadVideoTask?.cancel() + loadLikesTask?.cancel() } private fun setLoading(isLoading: Boolean) { @@ -856,14 +730,19 @@ 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 } + likeContainer.visibility = GONE + dislikeContainer.visibility = GONE + likeButton.clearOnCheckedChangeListeners() + dislikeButton.clearOnCheckedChangeListeners() + + loadLikesTask?.cancel() + loadLikesTask = + TaskHandler>( + StateApp.instance.scopeGetter, { + 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() @@ -877,130 +756,56 @@ class ShortView : ConstraintLayout { ), extraByteReferences = listOfNotNull(extraBytesRef) ) - val likes = queryReferencesResponse.countsList[0] - val dislikes = queryReferencesResponse.countsList[1] - val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray()) - val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray()) + Pair(ref, queryReferencesResponse) + }).success { (ref, queryReferencesResponse) -> + val likes = queryReferencesResponse.countsList[0] + val dislikes = queryReferencesResponse.countsList[1] + val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray()) + val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray()) + 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) + } - 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 - ) + 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) } } - } catch (e: Throwable) { - Logger.e(CommentsModalBottomSheet.Companion.TAG, "Failed to get polycentric likes/dislikes.", e) + + StatePolycentric.instance.updateLikeMap( + ref, args.hasLiked, args.hasDisliked + ) } } - } + + loadLikesTask?.run(video) } private fun loadVideo(url: String) { - loadVideoJob?.cancel() + loadVideoTask?.cancel() videoDetails = null + _lastVideoSource = null + _lastAudioSource = null + _lastSubtitleSource = null - loadVideoJob = CoroutineScope(Dispatchers.Main).launch { - setLoading(true) - _lastVideoSource = null - _lastAudioSource = null - _lastSubtitleSource = null - - val result = try { - withContext(StateApp.instance.scope.coroutineContext) { - StatePlatform.instance.getContentDetails(url).await() - } - } catch (_: CancellationException) { - return@launch - } catch (e: NoPlatformClientException) { - Logger.w(TAG, "exception", e) - - UIDialogs.showDialog( - context, R.drawable.ic_sources, "No source enabled to support this video\n(${url})", null, null, 0, UIDialogs.Action( - "Close", { }, UIDialogs.ActionStyle.PRIMARY - ) - ) - return@launch - } catch (e: ScriptLoginRequiredException) { - Logger.w(TAG, "exception", e) - UIDialogs.showDialog(context, R.drawable.ic_security, "Authentication", e.message, null, 0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Login", { - val id = e.config.let { if (it is SourcePluginConfig) it.id else null } - val didLogin = - if (id == null) false else StatePlugins.instance.loginPlugin(context, id) { - loadVideo(url) - } - if (!didLogin) UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login") - }, UIDialogs.ActionStyle.PRIMARY) - ) - return@launch - } catch (e: ContentNotAvailableYetException) { - Logger.w(TAG, "exception", e) - UIDialogs.showSingleButtonDialog(context, R.drawable.ic_schedule, "Video is available in ${e.availableWhen}.", "Close") { } - return@launch - } catch (e: ScriptImplementationException) { - Logger.w(TAG, "exception", e) - UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), e, { loadVideo(url) }, null, mainFragment) - return@launch - } catch (e: ScriptAgeException) { - Logger.w(TAG, "exception", e) - UIDialogs.showDialog( - context, R.drawable.ic_lock, "Age restricted video", e.message, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY) - ) - return@launch - } catch (e: ScriptUnavailableException) { - Logger.w(TAG, "exception", e) - if (video?.datetime == null || video?.datetime!! < OffsetDateTime.now() - .minusHours(1) - ) { - 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) - ) - } - - video?.let { StatePlatform.instance.clearContentDetailCache(it.url) } - return@launch - } catch (e: ScriptException) { - Logger.w(TAG, "exception", e) - - UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), e, { loadVideo(url) }, null, mainFragment) - return@launch - } catch (e: Throwable) { - Logger.w(ChannelFragment.TAG, "Failed to load video.", e) - UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), e, { loadVideo(url) }, null, mainFragment) - return@launch - } - - if (result !is IPlatformVideoDetails) { - Logger.w( - TAG, "Wrong content type", IllegalStateException("Expected media content, found ${result.contentType}") - ) - return@launch - } - - // if it's been canceled then don't set the video details - if (!isActive) { - return@launch - } + setLoading(true) + loadVideoTask = TaskHandler( + StateApp.instance.scopeGetter, { + val result = StatePlatform.instance.getContentDetails(it).await() + if (result !is IPlatformVideoDetails) throw IllegalStateException("Expected media content, found ${result.contentType}") + return@TaskHandler result + }).success { result -> videoDetails = result video = result @@ -1009,7 +814,48 @@ class ShortView : ConstraintLayout { setLoading(false) if (playWhenReady) playVideo() + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showDialog( + context, R.drawable.ic_sources, "No source enabled to support this video\n(${url})", null, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY) + ) + }.exception { e -> + Logger.w(TAG, "exception", e) + UIDialogs.showDialog( + context, R.drawable.ic_security, "Authentication", e.message, null, 0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Login", { + val id = e.config.let { if (it is SourcePluginConfig) it.id else null } + val didLogin = + if (id == null) false else StatePlugins.instance.loginPlugin(context, id) { + loadVideo(url) + } + if (!didLogin) UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login") + }, UIDialogs.ActionStyle.PRIMARY) + ) + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showSingleButtonDialog(context, R.drawable.ic_schedule, "Video is available in ${it.availableWhen}.", "Close") { } + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, { loadVideo(url) }, null, mainFragment) + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showDialog( + context, R.drawable.ic_lock, "Age restricted video", it.message, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY) + ) + }.exception { + Logger.w(TAG, "exception", it) + 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) + ) + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), it, { loadVideo(url) }, null, mainFragment) + }.exception { + Logger.w(ChannelFragment.TAG, "Failed to load video.", it) + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, { loadVideo(url) }, null, mainFragment) } + + loadVideoTask?.run(url) } private fun playVideo(resumePositionMs: Long = 0) { @@ -1020,8 +866,6 @@ class ShortView : ConstraintLayout { return } - updateQualitySourcesOverlay(videoDetails, null) - try { val videoSource = _lastVideoSource ?: player.getPreferredVideoSource(videoDetails, Settings.instance.playback.getCurrentPreferredQualityPixelCount()) 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 a9c2f8af..a6cdc882 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 @@ -17,15 +17,10 @@ import com.futo.platformplayer.UIDialogs 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.constructs.TaskHandler import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePlatform -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlin.coroutines.cancellation.CancellationException @UnstableApi class ShortsFragment : MainFragment() { @@ -33,8 +28,8 @@ class ShortsFragment : MainFragment() { override val isTab: Boolean = true override val hasBottomBar: Boolean get() = true - private var loadPagerJob: Job? = null - private var nextPageJob: Job? = null + private var loadPagerTask: TaskHandler>? = null + private var nextPageTask: TaskHandler>? = null private var shortsPager: IPager? = null private val videos: MutableList = mutableListOf() @@ -65,10 +60,6 @@ class ShortsFragment : MainFragment() { setLoading(true) - if (loadPagerJob?.isActive == false && videos.isEmpty()) { - loadPager() - } - Logger.i(TAG, "Creating adapter") val customViewAdapter = CustomViewAdapter(videos, layoutInflater, this@ShortsFragment, overlayQualityContainer) { @@ -80,7 +71,8 @@ class ShortsFragment : MainFragment() { customViewAdapter.onResetTriggered.subscribe { setLoading(true) loadPager() - loadPagerJob!!.invokeOnCompletion { + + loadPagerTask!!.success { setLoading(false) } } @@ -89,97 +81,105 @@ class ShortsFragment : MainFragment() { this.customViewAdapter = customViewAdapter - loadPagerJob!!.invokeOnCompletion { - viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { - fun play(adapter: CustomViewAdapter, position: Int) { - val recycler = (viewPager.getChildAt(0) as RecyclerView) - val viewHolder = - recycler.findViewHolderForAdapterPosition(position) as CustomViewHolder? + if (loadPagerTask == null && videos.isEmpty()) { + loadPager() - if (viewHolder == null) { - adapter.needToPlay = position - } else { - val focusedView = viewHolder.shortView - focusedView.play() - adapter.previousShownView = focusedView - } - } - - override fun onPageSelected(position: Int) { - val adapter = (viewPager.adapter as CustomViewAdapter) - if (adapter.previousShownView == null) { - // play if this page selection didn't trigger by a swipe from another page - play(adapter, position) - } else { - adapter.previousShownView?.stop() - adapter.previousShownView = null - adapter.newPosition = position - } - } - - // wait for the state to idle to prevent UI lag - override fun onPageScrollStateChanged(state: Int) { - super.onPageScrollStateChanged(state) - if (state == ViewPager2.SCROLL_STATE_IDLE) { - val adapter = (viewPager.adapter as CustomViewAdapter) - val position = adapter.newPosition - if (position == null) { - return - } - adapter.newPosition = null - - play(adapter, position) - } - } - }) - setLoading(false) + loadPagerTask!!.success { + applyData() + } + } else { + applyData() } } - private fun nextPage() { - nextPageJob?.cancel() + private fun applyData() { + val viewPager = viewPager!! + viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + fun play(adapter: CustomViewAdapter, position: Int) { + val recycler = (viewPager.getChildAt(0) as RecyclerView) + val viewHolder = + recycler.findViewHolderForAdapterPosition(position) as CustomViewHolder? - nextPageJob = CoroutineScope(Dispatchers.Main).launch { - try { - withContext(Dispatchers.IO) { - shortsPager!!.nextPage() + if (viewHolder == null) { + adapter.needToPlay = position + } else { + val focusedView = viewHolder.shortView + focusedView.play() + adapter.previousShownView = focusedView } - } catch (_: CancellationException) { - return@launch } - // if it's been canceled then don't update the results - if (!isActive) { - return@launch + override fun onPageSelected(position: Int) { + val adapter = (viewPager.adapter as CustomViewAdapter) + if (adapter.previousShownView == null) { + // play if this page selection didn't trigger by a swipe from another page + play(adapter, position) + } else { + adapter.previousShownView?.stop() + adapter.previousShownView = null + adapter.newPosition = position + } } - val newVideos = shortsPager!!.getResults() - CoroutineScope(Dispatchers.Main).launch { + // wait for the state to idle to prevent UI lag + override fun onPageScrollStateChanged(state: Int) { + super.onPageScrollStateChanged(state) + if (state == ViewPager2.SCROLL_STATE_IDLE) { + val adapter = (viewPager.adapter as CustomViewAdapter) + val position = adapter.newPosition + if (position == null) { + return + } + adapter.newPosition = null + + play(adapter, position) + } + } + }) + setLoading(false) + } + + private fun nextPage() { + nextPageTask?.cancel() + + val nextPageTask = + TaskHandler>(StateApp.instance.scopeGetter, { + shortsPager!!.nextPage() + + return@TaskHandler shortsPager!!.getResults() + }).success { newVideos -> val prevCount = customViewAdapter!!.itemCount videos.addAll(newVideos) customViewAdapter!!.notifyItemRangeInserted(prevCount, newVideos.size) + nextPageTask = null } - } + + nextPageTask.run(this) + + this.nextPageTask = nextPageTask } // we just completely reset the data structure so we want to tell the adapter that @SuppressLint("NotifyDataSetChanged") private fun loadPager() { - loadPagerJob?.cancel() + loadPagerTask?.cancel() - // if the view pager exists go back to the beginning - videos.clear() - viewPager?.adapter?.notifyDataSetChanged() - viewPager?.currentItem = 0 + val loadPagerTask = + TaskHandler>(StateApp.instance.scopeGetter, { + val pager = StatePlatform.instance.getShorts() - loadPagerJob = CoroutineScope(Dispatchers.Main).launch { - val pager = try { - withContext(Dispatchers.IO) { - StatePlatform.instance.getShorts() - } - } catch (_: CancellationException) { - return@launch - } catch (err: Throwable) { + return@TaskHandler pager + }).success { pager -> + videos.clear() + videos.addAll(pager.getResults()) + shortsPager = pager + + // if the view pager exists go back to the beginning + viewPager?.adapter?.notifyDataSetChanged() + viewPager?.currentItem = 0 + + loadPagerTask = null + }.exception { err -> val message = "Unable to load shorts $err" Logger.i(TAG, message) if (context != null) { @@ -189,21 +189,12 @@ class ShortsFragment : MainFragment() { ) ) } - return@launch + return@exception } - // if it's been canceled then don't set the video pager - if (!isActive) { - return@launch - } + this.loadPagerTask = loadPagerTask - videos.clear() - videos.addAll(pager.getResults()) - shortsPager = pager - - // if the viewPager exists then trigger data changed - viewPager?.adapter?.notifyDataSetChanged() - } + loadPagerTask.run(this) } private fun setLoading(isLoading: Boolean) { @@ -223,6 +214,10 @@ class ShortsFragment : MainFragment() { override fun onDestroy() { super.onDestroy() + loadPagerTask?.cancel() + loadPagerTask = null + nextPageTask?.cancel() + nextPageTask = null customViewAdapter?.previousShownView?.stop() } @@ -265,7 +260,6 @@ class ShortsFragment : MainFragment() { override fun onViewRecycled(holder: CustomViewHolder) { super.onViewRecycled(holder) holder.shortView.cancel() - } override fun onViewAttachedToWindow(holder: CustomViewHolder) { 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 8654600e..90da8898 100644 --- a/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt +++ b/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt @@ -34,15 +34,18 @@ class PlayerManager { @Synchronized fun attach(view: PlayerView, stateName: String) { - if(view != _currentView || _currentView?.player == null) { - _currentView?.player = null; - switchState(stateName); - view.player = player; - _currentView = view; + if (view != _currentView) { + _currentView?.player = null + _currentView = null + switchState(stateName) + view.player = player + _currentView = view } } + fun detach() { - _currentView?.player = null; + _currentView?.player = null + _currentView = null } fun getState(name: String): PlayerState { diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt index 6d82a3ec..67a56545 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt @@ -72,8 +72,6 @@ import kotlin.math.abs abstract class FutoVideoPlayerBase : RelativeLayout { private val TAG = "FutoVideoPlayerBase" -// private val TEMP_DIRECTORY = StateApp.instance.getTempDirectory(); - private var _mediaSource: MediaSource? = null; var lastVideoSource: IVideoSource? = null diff --git a/app/src/main/res/drawable/ic_comment.xml b/app/src/main/res/drawable/ic_comment.xml new file mode 100644 index 00000000..f67f9d5c --- /dev/null +++ b/app/src/main/res/drawable/ic_comment.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_smart_display.xml b/app/src/main/res/drawable/ic_smart_display.xml index 68758978..f9ae21c4 100644 --- a/app/src/main/res/drawable/ic_smart_display.xml +++ b/app/src/main/res/drawable/ic_smart_display.xml @@ -5,5 +5,5 @@ android:viewportHeight="960"> + android:pathData="M405.85,617L619.69,478.77L405.85,341.31L405.85,617ZM175.38,760Q152.33,760 136.16,743.84Q120,727.67 120,704.62L120,255.38Q120,232.33 136.16,216.16Q152.33,200 175.38,200L784.62,200Q807.67,200 823.84,216.16Q840,232.33 840,255.38L840,704.62Q840,727.67 823.84,743.84Q807.67,760 784.62,760L175.38,760ZM175.38,729.23L784.62,729.23Q793.85,729.23 801.54,721.54Q809.23,713.85 809.23,704.62L809.23,255.38Q809.23,246.15 801.54,238.46Q793.85,230.77 784.62,230.77L175.38,230.77Q166.15,230.77 158.46,238.46Q150.77,246.15 150.77,255.38L150.77,704.62Q150.77,713.85 158.46,721.54Q166.15,729.23 175.38,729.23ZM150.77,729.23Q150.77,729.23 150.77,721.54Q150.77,713.85 150.77,704.62L150.77,255.38Q150.77,246.15 150.77,238.46Q150.77,230.77 150.77,230.77L150.77,230.77Q150.77,230.77 150.77,238.46Q150.77,246.15 150.77,255.38L150.77,704.62Q150.77,713.85 150.77,721.54Q150.77,729.23 150.77,729.23Z"/> diff --git a/app/src/main/res/drawable/ic_smart_display_filled.xml b/app/src/main/res/drawable/ic_smart_display_filled.xml new file mode 100644 index 00000000..14245c9c --- /dev/null +++ b/app/src/main/res/drawable/ic_smart_display_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_thumb_down.xml b/app/src/main/res/drawable/ic_thumb_down.xml index 8de5f492..3a80a3e3 100644 --- a/app/src/main/res/drawable/ic_thumb_down.xml +++ b/app/src/main/res/drawable/ic_thumb_down.xml @@ -1,9 +1,9 @@ + android:viewportWidth="960" + android:viewportHeight="960"> + android:pathData="M262.65,192.31L666,192.31L666,628.92L415.69,880L403.6,871.19Q398.38,866.31 395.38,859.23Q392.38,852.15 392.38,843.77L392.38,839.92L433.54,628.92L136.85,628.92Q115.46,628.92 98.46,611.92Q81.46,594.92 81.46,573.54L81.46,523.18Q81.46,517.62 81.12,511.27Q80.77,504.92 83,499.46L195.15,237.15Q202.49,218.21 222.44,205.26Q242.39,192.31 262.65,192.31ZM635.23,223.08L256.69,223.08Q248.23,223.08 239.38,227.69Q230.54,232.31 225.92,243.08L112.23,512.08L112.23,573.54Q112.23,583.54 119.15,590.85Q126.08,598.15 136.85,598.15L470.62,598.15L424.54,829.46L635.23,615.46L635.23,223.08ZM635.23,615.46L635.23,615.46L635.23,598.15L635.23,598.15Q635.23,598.15 635.23,590.85Q635.23,583.54 635.23,573.54L635.23,512.08L635.23,243.08Q635.23,232.31 635.23,227.69Q635.23,223.08 635.23,223.08L635.23,223.08L635.23,615.46ZM666,628.92L666,598.15L809,598.15L809,223.08L666,223.08L666,192.31L839.77,192.31L839.77,628.92L666,628.92Z"/> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_thumb_down_filled.xml b/app/src/main/res/drawable/ic_thumb_down_filled.xml new file mode 100644 index 00000000..5517d2c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_thumb_down_filled.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_thumb_up.xml b/app/src/main/res/drawable/ic_thumb_up.xml index fdcf53d4..489f31d9 100644 --- a/app/src/main/res/drawable/ic_thumb_up.xml +++ b/app/src/main/res/drawable/ic_thumb_up.xml @@ -1,9 +1,9 @@ + android:viewportWidth="960" + android:viewportHeight="960"> + android:pathData="M696.77,800L293.54,800L293.54,363.38L543.08,112.31L555.84,121.12Q561.15,126 564.15,133.08Q567.15,140.15 567.15,147.77L567.15,152.38L525.23,363.38L822.69,363.38Q844.08,363.38 861.08,380.38Q878.08,397.38 878.08,418.77L878.08,469.13Q878.08,474.69 878.04,481.04Q878,487.38 875.77,492.85L764.38,755.15Q756,774.22 736.19,787.11Q716.38,800 696.77,800ZM324.31,769.23L702.85,769.23Q710.54,769.23 719.77,764.62Q729,760 733.62,749.23L847.31,480.23L847.31,418.77Q847.31,408.77 840,401.46Q832.69,394.15 822.69,394.15L488.92,394.15L534.23,162.85L324.31,376.85L324.31,769.23ZM324.31,376.85L324.31,376.85L324.31,394.15L324.31,394.15Q324.31,394.15 324.31,401.46Q324.31,408.77 324.31,418.77L324.31,480.23L324.31,749.23Q324.31,760 324.31,764.62Q324.31,769.23 324.31,769.23L324.31,769.23L324.31,376.85ZM293.54,363.38L293.54,394.15L150.54,394.15L150.54,769.23L293.54,769.23L293.54,800L119.77,800L119.77,363.38L293.54,363.38Z"/> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_thumb_up_filled.xml b/app/src/main/res/drawable/ic_thumb_up_filled.xml new file mode 100644 index 00000000..03a4da48 --- /dev/null +++ b/app/src/main/res/drawable/ic_thumb_up_filled.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/thumb_down_selector.xml b/app/src/main/res/drawable/thumb_down_selector.xml new file mode 100644 index 00000000..abc61af3 --- /dev/null +++ b/app/src/main/res/drawable/thumb_down_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/thumb_up_selector.xml b/app/src/main/res/drawable/thumb_up_selector.xml new file mode 100644 index 00000000..0cb744f0 --- /dev/null +++ b/app/src/main/res/drawable/thumb_up_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_short.xml b/app/src/main/res/layout/view_short.xml index b3a558d5..159e6516 100644 --- a/app/src/main/res/layout/view_short.xml +++ b/app/src/main/res/layout/view_short.xml @@ -15,21 +15,16 @@ app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" /> - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 603193fb..ba81d2b6 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -39,6 +39,10 @@ #ACACAC #C25353 + + #33555555 + #33FFFFFF + #8F4C38 #FFFFFF diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d073a49..7156ff6d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,6 +36,7 @@ Preferred Quality Default quality for watching a video Update + Refresh Close Never Select any of the following available import options.