diff --git a/app/build.gradle b/app/build.gradle index 8d55d000..5f38c749 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -143,6 +143,10 @@ android { } buildFeatures { buildConfig true + compose true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.2" } sourceSets { main { @@ -215,6 +219,7 @@ 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") @@ -228,4 +233,10 @@ 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 25febdb1..647cf6d8 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -1,13 +1,11 @@ package com.futo.platformplayer.activities import android.annotation.SuppressLint -import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.pm.PackageManager import android.content.res.Configuration -import android.media.AudioManager import android.net.Uri import android.os.Bundle import android.os.StrictMode @@ -57,6 +55,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsF import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment +import com.futo.platformplayer.fragment.mainactivity.main.ShortsFragment import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment @@ -74,7 +73,6 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.UrlVideoWithTime -import com.futo.platformplayer.receivers.MediaButtonReceiver import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateBackup @@ -161,6 +159,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { lateinit var _fragMainRemotePlaylist: RemotePlaylistFragment; lateinit var _fragWatchlist: WatchLaterFragment; lateinit var _fragHistory: HistoryFragment; + lateinit var _fragShorts: ShortsFragment; lateinit var _fragSourceDetail: SourceDetailFragment; lateinit var _fragDownloads: DownloadsFragment; lateinit var _fragImportSubscriptions: ImportSubscriptionsFragment; @@ -315,6 +314,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragPostDetail = PostDetailFragment.newInstance(); _fragWatchlist = WatchLaterFragment.newInstance(); _fragHistory = HistoryFragment.newInstance(); + _fragShorts = ShortsFragment.newInstance(); _fragSourceDetail = SourceDetailFragment.newInstance(); _fragDownloads = DownloadsFragment(); _fragImportSubscriptions = ImportSubscriptionsFragment.newInstance(); @@ -1088,6 +1088,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { if (segment.isMainView) { var transaction = supportFragmentManager.beginTransaction(); + transaction.setReorderingAllowed(true) if (segment.topBar != null) { if (segment.topBar != fragCurrent.topBar) { transaction = transaction @@ -1188,6 +1189,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { PostDetailFragment::class -> _fragPostDetail as T; WatchLaterFragment::class -> _fragWatchlist as T; HistoryFragment::class -> _fragHistory as T; + ShortsFragment::class -> _fragShorts as T; SourceDetailFragment::class -> _fragSourceDetail as T; DownloadsFragment::class -> _fragDownloads as T; ImportSubscriptionsFragment::class -> _fragImportSubscriptions as T; 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 c4afcf74..2b7c055a 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 @@ -386,16 +386,17 @@ class MenuBottomBarFragment : MainActivityFragment() { 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_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate() }), - ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { 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(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() }), ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate() }), ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate() }), - ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, { + ButtonDefinition(11, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, { val c = it.context ?: return@ButtonDefinition; Logger.i(TAG, "settings preventPictureInPicture()"); it.requireFragment().preventPictureInPicture(); 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 new file mode 100644 index 00000000..bba0dcee --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt @@ -0,0 +1,427 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.app.Dialog +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Animatable +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.SoundEffectConstants +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material.icons.outlined.ThumbUp +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.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.lifecycle.lifecycleScope +import androidx.media3.common.util.UnstableApi +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.futo.platformplayer.R +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.streams.sources.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.downloads.VideoLocal +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.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlugins +import com.futo.platformplayer.views.video.FutoShortPlayer +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +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 java.time.OffsetDateTime +import kotlin.coroutines.cancellation.CancellationException + +@OptIn(ExperimentalMaterial3Api::class) +@UnstableApi +class ShortView : ConstraintLayout { + private var mainFragment: MainFragment? = null + private val player: FutoShortPlayer + private val overlayLoading: FrameLayout + private val overlayLoadingSpinner: ImageView + + private var url: String? = null + private var video: IPlatformVideo? = null + private var videoDetails: IPlatformVideoDetails? = null + + private var playWhenReady = false + + private var _lastVideoSource: IVideoSource? = null + private var _lastAudioSource: IAudioSource? = null + private var _lastSubtitleSource: ISubtitleSource? = null + + private var loadVideoJob: Job? = null + + private val bottomSheet: ModalBottomSheet = ModalBottomSheet() + + // Required constructor for XML inflation + constructor(context: Context) : super(context) { + inflate(context, R.layout.view_short, this) + + player = findViewById(R.id.short_player) + overlayLoading = findViewById(R.id.short_view_loading_overlay) + overlayLoadingSpinner = findViewById(R.id.short_view_loader) + + setupComposeView() + } + + // Required constructor for XML inflation with attributes + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { + inflate(context, R.layout.view_short, this) + + player = findViewById(R.id.short_player) + overlayLoading = findViewById(R.id.short_view_loading_overlay) + overlayLoadingSpinner = findViewById(R.id.short_view_loader) + + setupComposeView() + } + + // Required constructor for XML inflation with attributes and style + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { + inflate(context, R.layout.view_short, this) + + player = findViewById(R.id.short_player) + overlayLoading = findViewById(R.id.short_view_loading_overlay) + overlayLoadingSpinner = findViewById(R.id.short_view_loader) + + setupComposeView() + } + + constructor(inflater: LayoutInflater, fragment: MainFragment) : super(inflater.context) { + this.mainFragment = fragment + + 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) + + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT + ) + } + + 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) } + + val tint = Color.White + + val alpha = 0.2f + val rippleConfiguration = + RippleConfiguration(color = tint, rippleAlpha = RippleAlpha(alpha, alpha, alpha, alpha)) + + 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, + ) + } + } + } + } + } + } + } + + fun setMainFragment(fragment: MainFragment) { + this.mainFragment = fragment + } + + fun setVideo(url: String) { + if (url == this.url) { + return + } + + loadVideo(url) + } + + fun setVideo(video: IPlatformVideo) { + if (url == video.url) { + return + } + this.video = video + + loadVideo(video.url) + } + + fun setVideo(videoDetails: IPlatformVideoDetails) { + if (url == videoDetails.url) { + return + } + + this.videoDetails = videoDetails + } + + fun play() { + player.attach() + playVideo() + } + + fun stop() { + playWhenReady = false + + player.clear() + player.detach() + } + + fun detach() { + loadVideoJob?.cancel() + } + + 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() + } + } + + private fun loadVideo(url: String) { + loadVideoJob?.cancel() + + 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 (e: 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 + } + + videoDetails = result + video = result + + setLoading(false) + + if (playWhenReady) playVideo() + } + } + + private fun playVideo(resumePositionMs: Long = 0) { + val video = videoDetails + + if (video === null) { + playWhenReady = true + return + } + + bottomSheet.show(mainFragment!!.childFragmentManager, ModalBottomSheet.TAG) + + try { + val videoSource = _lastVideoSource + ?: player.getPreferredVideoSource(video, Settings.instance.playback.getCurrentPreferredQualityPixelCount()) + val audioSource = _lastAudioSource + ?: player.getPreferredAudioSource(video, Settings.instance.playback.getPrimaryLanguage(context)) + val subtitleSource = _lastSubtitleSource + ?: (if (video is VideoLocal) video.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) + return + } + + val thumbnail = video.thumbnails.getHQThumbnail() + if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap() + .load(thumbnail).into(object : CustomTarget() { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + player.setArtwork(BitmapDrawable(resources, resource)) + } + + override fun onLoadCleared(placeholder: Drawable?) { + player.setArtwork(null) + } + }) + else player.setArtwork(null) + player.setSource(videoSource, audioSource, play = true, keepSubtitles = false, resume = resumePositionMs > 0) + if (subtitleSource != null) player.swapSubtitles(mainFragment!!.lifecycleScope, subtitleSource) + player.seekTo(resumePositionMs) + + _lastVideoSource = videoSource + _lastAudioSource = audioSource + _lastSubtitleSource = subtitleSource + } catch (ex: UnsupportedCastException) { + Logger.e(TAG, "Failed to load cast media", ex) + UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.unsupported_cast_format), ex) + } catch (ex: Throwable) { + Logger.e(TAG, "Failed to load media", ex) + UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex) + } + } + + companion object { + const val TAG = "VideoDetailView" + } + + class ModalBottomSheet : BottomSheetDialogFragment() { + override fun onCreateDialog( + savedInstanceState: Bundle?, + ): Dialog { + val bottomSheetDialog = BottomSheetDialog( + requireContext() + ) + bottomSheetDialog.setContentView(R.layout.modal_comments) + + val composeView = bottomSheetDialog.findViewById(R.id.compose_view) + + 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 + } + + companion object { + const val TAG = "ModalBottomSheet" + } + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..b11a30a4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt @@ -0,0 +1,96 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +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 + +@UnstableApi +class ShortsFragment : MainFragment() { + override val isMainView: Boolean = true + override val isTab: Boolean = true + override val hasBottomBar: Boolean get() = true + + private var previousShownView: ShortView? = null + + private lateinit var viewPager: ViewPager2 + 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" + ) + + override fun onCreateMainView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + return inflater.inflate(R.layout.fragment_shorts, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewPager = view.findViewById(R.id.viewPager) + + customViewAdapter = CustomViewAdapter(urls, layoutInflater, this) + 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) { + previousShownView?.stop() + + val focusedView = + ((viewPager[0] as RecyclerView).findViewHolderForAdapterPosition(position) as CustomViewHolder).shortView + focusedView.play() + + + previousShownView = focusedView + } + + }) + + } + + override fun onPause() { + super.onPause() + previousShownView?.stop() + } + + companion object { + private const val TAG = "ShortsFragment" + + fun newInstance() = ShortsFragment() + } + + class CustomViewAdapter( + private val urls: List, private val inflater: LayoutInflater, private val fragment: MainFragment + ) : RecyclerView.Adapter() { + @OptIn(UnstableApi::class) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder { + val shortView = ShortView(inflater, fragment) + return CustomViewHolder(shortView) + } + + @OptIn(UnstableApi::class) + override fun onBindViewHolder(holder: CustomViewHolder, position: Int) { + holder.shortView.setVideo(urls[position]) + } + + @OptIn(UnstableApi::class) + override fun onViewRecycled(holder: CustomViewHolder) { + super.onViewRecycled(holder) + holder.shortView.detach() + } + + override fun getItemCount(): Int = urls.size + } + + @OptIn(UnstableApi::class) + class CustomViewHolder(val shortView: ShortView) : RecyclerView.ViewHolder(shortView) +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt index b8368ea5..b12889e8 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt @@ -38,6 +38,7 @@ class StatePlayer { //Players private var _exoplayer : PlayerManager? = null; private var _thumbnailExoPlayer : PlayerManager? = null; + private var _shortExoPlayer: PlayerManager? = null //Video Status var rotationLock: Boolean = false @@ -633,6 +634,13 @@ class StatePlayer { } return _thumbnailExoPlayer!!; } + fun getShortPlayerOrCreate(context: Context) : PlayerManager { + if(_shortExoPlayer == null) { + val player = createExoPlayer(context); + _shortExoPlayer = PlayerManager(player); + } + return _shortExoPlayer!!; + } @OptIn(UnstableApi::class) private fun createExoPlayer(context : Context): ExoPlayer { @@ -656,10 +664,13 @@ class StatePlayer { fun dispose(){ val player = _exoplayer; val thumbPlayer = _thumbnailExoPlayer; + val shortPlayer = _shortExoPlayer _exoplayer = null; _thumbnailExoPlayer = null; + _shortExoPlayer = null player?.release(); thumbPlayer?.release(); + shortPlayer?.release() } 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 new file mode 100644 index 00000000..9c2b003f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt @@ -0,0 +1,179 @@ +package com.futo.platformplayer.views.video + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.animation.LinearInterpolator +import androidx.annotation.OptIn +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.DefaultTimeBar +import androidx.media3.ui.PlayerView +import androidx.media3.ui.TimeBar +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +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.states.StatePlayer + +@UnstableApi +class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : + FutoVideoPlayerBase(PLAYER_STATE_NAME, context, attrs) { + + companion object { + private const val TAG = "FutoShortVideoPlayer" + private const val PLAYER_STATE_NAME: String = "ShortPlayer" + } + + private var playerAttached = false + + private val videoView: PlayerView + private val progressBar: DefaultTimeBar + + private val loadArtwork = object : CustomTarget() { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + setArtwork(BitmapDrawable(resources, resource)) + } + + override fun onLoadCleared(placeholder: Drawable?) { + setArtwork(null) + } + } + + private val player = StatePlayer.instance.getShortPlayerOrCreate(context) + + private var progressAnimator: ValueAnimator = createProgressBarAnimator() + + private var playerEventListener = object : Player.Listener { + override fun onEvents(player: Player, events: Player.Events) { + if (events.containsAny( + Player.EVENT_POSITION_DISCONTINUITY, Player.EVENT_IS_PLAYING_CHANGED, Player.EVENT_PLAYBACK_STATE_CHANGED + ) + ) { + if (player.duration >= 0) { + progressAnimator.duration = player.duration + setProgressBarDuration(player.duration) + progressAnimator.currentPlayTime = player.currentPosition + } + + if (player.isPlaying) { + if (!progressAnimator.isStarted) { + progressAnimator.start() + } + } else { + if (progressAnimator.isRunning) { + progressAnimator.cancel() + } + } + } + } + } + + init { + LayoutInflater.from(context).inflate(R.layout.view_short_player, this, true) + videoView = findViewById(R.id.video_player) + progressBar = findViewById(R.id.video_player_progress_bar) + + progressBar.addListener(object : TimeBar.OnScrubListener { + override fun onScrubStart(timeBar: TimeBar, position: Long) { + if (progressAnimator.isRunning) { + progressAnimator.cancel() + } + } + + override fun onScrubMove(timeBar: TimeBar, position: Long) {} + + override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { + if (canceled) { + progressAnimator.currentPlayTime = player.player.currentPosition + progressAnimator.start() + return + } + + // the progress bar should never be available to the user without the player being attached to this view + assert(playerAttached) + seekTo(position) + } + }) + } + + @OptIn(UnstableApi::class) + private fun createProgressBarAnimator(): ValueAnimator { + return ValueAnimator.ofFloat(0f, 1f).apply { + interpolator = LinearInterpolator() + + addUpdateListener { animation -> + val progress = animation.animatedValue as Float + val duration = animation.duration + progressBar.setPosition((progress * duration).toLong()) + } + } + } + + fun setProgressBarDuration(duration: Long) { + progressBar.setDuration(duration) + } + + /** + * Attaches this short player instance to the exo player instance for shorts + */ + fun attach() { + // connect the exo player for shorts to the view for this instance + player.attach(videoView, PLAYER_STATE_NAME) + + // direct the base player what exo player instance to use + changePlayer(player) + + playerAttached = true + + player.player.addListener(playerEventListener) + } + + fun detach() { + playerAttached = false + player.player.removeListener(playerEventListener) + player.detach() + } + + fun setPreview(video: IPlatformVideoDetails) { + if (video.live != null) { + setSource(video.live, null, play = true, keepSubtitles = false) + } else { + val videoSource = + VideoHelper.selectBestVideoSource(video.video, Settings.instance.playback.getPreferredPreviewQualityPixelCount(), PREFERED_VIDEO_CONTAINERS) + val audioSource = + VideoHelper.selectBestAudioSource(video.video, PREFERED_AUDIO_CONTAINERS, Settings.instance.playback.getPrimaryLanguage(context)) + if (videoSource == null && audioSource != null) { + val thumbnail = video.thumbnails.getHQThumbnail() + if (!thumbnail.isNullOrBlank()) { + Glide.with(videoView).asBitmap().load(thumbnail).into(loadArtwork) + } else { + Glide.with(videoView).clear(loadArtwork) + setArtwork(null) + } + } else { + Glide.with(videoView).clear(loadArtwork) + } + + setSource(videoSource, audioSource, play = true, keepSubtitles = false) + } + } + + @OptIn(UnstableApi::class) + fun setArtwork(drawable: Drawable?) { + if (drawable != null) { + videoView.defaultArtwork = drawable + videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_FILL + } else { + videoView.defaultArtwork = null + videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_OFF + } + } +} 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 c872ca02..c3eddb12 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 @@ -5,6 +5,7 @@ import android.net.Uri import android.util.AttributeSet import android.widget.RelativeLayout import androidx.annotation.OptIn +import androidx.constraintlayout.widget.ConstraintLayout import androidx.lifecycle.coroutineScope import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.media3.common.C @@ -68,7 +69,7 @@ import java.io.ByteArrayInputStream import java.io.File import kotlin.math.abs -abstract class FutoVideoPlayerBase : RelativeLayout { +abstract class FutoVideoPlayerBase : ConstraintLayout { private val TAG = "FutoVideoPlayerBase" private val TEMP_DIRECTORY = StateApp.instance.getTempDirectory(); diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 1708eeb4..df3abc69 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,9 +1,10 @@ - - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_overview_bottom_bar.xml b/app/src/main/res/layout/fragment_overview_bottom_bar.xml index 11c33b2c..30b6b752 100644 --- a/app/src/main/res/layout/fragment_overview_bottom_bar.xml +++ b/app/src/main/res/layout/fragment_overview_bottom_bar.xml @@ -38,7 +38,7 @@ + diff --git a/app/src/main/res/layout/modal_comments.xml b/app/src/main/res/layout/modal_comments.xml new file mode 100644 index 00000000..e079a671 --- /dev/null +++ b/app/src/main/res/layout/modal_comments.xml @@ -0,0 +1,231 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_short.xml b/app/src/main/res/layout/view_short.xml new file mode 100644 index 00000000..8ec7a845 --- /dev/null +++ b/app/src/main/res/layout/view_short.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_short_player.xml b/app/src/main/res/layout/view_short_player.xml new file mode 100644 index 00000000..bfd8762d --- /dev/null +++ b/app/src/main/res/layout/view_short_player.xml @@ -0,0 +1,31 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 6d142d08..603193fb 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -38,4 +38,147 @@ #B3000000 #ACACAC #C25353 + + + #8F4C38 + #FFFFFF + #FFDBD1 + #723523 + #77574E + #FFFFFF + #FFDBD1 + #5D4037 + #6C5D2F + #FFFFFF + #F5E1A7 + #534619 + #BA1A1A + #FFFFFF + #FFDAD6 + #93000A + #FFF8F6 + #231917 + #FFF8F6 + #231917 + #F5DED8 + #53433F + #85736E + #D8C2BC + #000000 + #392E2B + #FFEDE8 + #FFB5A0 + #FFDBD1 + #3A0B01 + #FFB5A0 + #723523 + #FFDBD1 + #2C150F + #E7BDB2 + #5D4037 + #F5E1A7 + #231B00 + #D8C58D + #534619 + #E8D6D2 + #FFF8F6 + #FFFFFF + #FFF1ED + #FCEAE5 + #F7E4E0 + #F1DFDA + #5D2514 + #FFFFFF + #A15A45 + #FFFFFF + #4B2F28 + #FFFFFF + #87655C + #FFFFFF + #41350A + #FFFFFF + #7B6C3C + #FFFFFF + #740006 + #FFFFFF + #CF2C27 + #FFFFFF + #FFF8F6 + #231917 + #FFF8F6 + #180F0D + #F5DED8 + #41332F + #5F4F4A + #7B6964 + #000000 + #392E2B + #FFEDE8 + #FFB5A0 + #A15A45 + #FFFFFF + #84422F + #FFFFFF + #87655C + #FFFFFF + #6D4D45 + #FFFFFF + #7B6C3C + #FFFFFF + #615426 + #FFFFFF + #D4C3BE + #FFF8F6 + #FFFFFF + #FFF1ED + #F7E4E0 + #EBD9D4 + #DFCEC9 + #501B0B + #FFFFFF + #753725 + #FFFFFF + #3F261E + #FFFFFF + #60423A + #FFFFFF + #362B02 + #FFFFFF + #55481C + #FFFFFF + #600004 + #FFFFFF + #98000A + #FFFFFF + #FFF8F6 + #231917 + #FFF8F6 + #000000 + #F5DED8 + #000000 + #372925 + #554641 + #000000 + #392E2B + #FFFFFF + #FFB5A0 + #753725 + #FFFFFF + #592111 + #FFFFFF + #60423A + #FFFFFF + #472C24 + #FFFFFF + #55481C + #FFFFFF + #3D3206 + #FFFFFF + #C6B5B1 + #FFF8F6 + #FFFFFF + #FFEDE8 + #F1DFDA + #E2D1CC + #D4C3BE \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d4df1905..2285becf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,6 +25,7 @@ Failed to retrieve data, are you connected? Settings History + Shorts Sources Buy FAQ diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 8fa8f2d1..3f2b80c5 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -49,6 +49,42 @@ @style/Theme.FutoVideo.ListView @style/Theme.FutoVideo.TextView @style/Theme.FutoVideo.CheckedTextView + + + @color/md_theme_inversePrimary + @color/md_theme_primaryContainer + @color/md_theme_onPrimaryContainer + @color/md_theme_secondaryContainer + @color/md_theme_onSecondaryContainer + @color/md_theme_tertiary + @color/md_theme_onTertiary + @color/md_theme_tertiaryContainer + @color/md_theme_onTertiaryContainer + @color/md_theme_surfaceVariant + @color/md_theme_onSurfaceVariant + @color/md_theme_inverseSurface + @color/md_theme_inverseOnSurface + @color/md_theme_outline + @color/md_theme_errorContainer + @color/md_theme_onErrorContainer + @style/TextAppearance.Material3.DisplayLarge + @style/TextAppearance.Material3.DisplayMedium + @style/TextAppearance.Material3.DisplaySmall + @style/TextAppearance.Material3.HeadlineLarge + @style/TextAppearance.Material3.HeadlineMedium + @style/TextAppearance.Material3.HeadlineSmall + @style/TextAppearance.Material3.TitleLarge + @style/TextAppearance.Material3.TitleMedium + @style/TextAppearance.Material3.TitleSmall + @style/TextAppearance.Material3.BodyLarge + @style/TextAppearance.Material3.BodyMedium + @style/TextAppearance.Material3.BodySmall + @style/TextAppearance.Material3.LabelLarge + @style/TextAppearance.Material3.LabelMedium + @style/TextAppearance.Material3.LabelSmall + @style/ShapeAppearance.Material3.SmallComponent + @style/ShapeAppearance.Material3.MediumComponent + @style/ShapeAppearance.Material3.LargeComponent