diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2ad8451a352..f99f81976e5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.dokka) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlin.compose) } val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) @@ -199,6 +200,7 @@ android { buildFeatures { buildConfig = true viewBinding = true + compose = true } packaging { @@ -239,6 +241,17 @@ dependencies { implementation(libs.material) implementation(libs.constraintlayout) + // Jetpack Compose + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.activity.compose) + + // Coil Image Loading implementation(libs.bundles.coil) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ee4c978f2be..71ce4f515f4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -275,6 +275,8 @@ android:name=".ui.ControllerActivity" android:exported="false" /> + + diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt index c316fae1715..e0cf042be2d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt @@ -1,26 +1,24 @@ package com.lagradost.cloudstream3.ui.result -import android.animation.Animator import android.annotation.SuppressLint import android.app.Dialog import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.animation.DecelerateInterpolator import android.widget.Toast -import androidx.appcompat.app.AlertDialog +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible -import androidx.core.widget.NestedScrollView import androidx.lifecycle.ViewModelProvider -import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.button.MaterialButton +import com.lagradost.cloudstream3.ActorData import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.DubStatus -import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse @@ -28,43 +26,30 @@ import com.lagradost.cloudstream3.databinding.FragmentResultTvBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable -import com.lagradost.cloudstream3.services.SubscriptionWorkManager import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.WatchType -import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup import com.lagradost.cloudstream3.ui.player.ExtractorLinkGenerator import com.lagradost.cloudstream3.ui.player.GeneratorPlayer -import com.lagradost.cloudstream3.ui.player.NEXT_WATCH_EPISODE_PERCENTAGE import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment -import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo import com.lagradost.cloudstream3.ui.result.ResultFragment.getStoredData import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent -import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_FOCUSED -import com.lagradost.cloudstream3.ui.search.SearchAdapter +import com.lagradost.cloudstream3.ui.result.compose.MovieDetailsComposeScreen +import com.lagradost.cloudstream3.ui.result.compose.model.MovieTrailerData +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsTheme +import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD +import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchHelper -import com.lagradost.cloudstream3.ui.setRecycledViewPool -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull -import com.lagradost.cloudstream3.utils.AppContextUtils.html -import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache -import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers -import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogInstant import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.UIHelper.populateChips +import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat -import com.lagradost.cloudstream3.utils.getImageFromDrawable -import com.lagradost.cloudstream3.utils.setText -import com.lagradost.cloudstream3.utils.setTextHtml +import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.txt class ResultFragmentTv : BaseFragment( @@ -73,6 +58,25 @@ class ResultFragmentTv : BaseFragment( private lateinit var viewModel: ResultViewModel2 + private var composePageState by mutableStateOf?>(null) + private var composeEpisodesState by mutableStateOf>(emptyList()) + private var composeRecommendationsState by mutableStateOf>(emptyList()) + private var composeActorsState by mutableStateOf>(emptyList()) + private var composeWatchStatusState by mutableStateOf(WatchType.NONE) + private var composeFavoriteStatusState by mutableStateOf(false) + private var composeRawSeasonsState by mutableStateOf>>(emptyList()) + private var composeSeasonsState by mutableStateOf>(emptyList()) + private var composeSelectedSeasonIndexState by mutableStateOf(0) + private var composeDubSubSelectionsState by mutableStateOf>>(emptyList()) + private var composeDubSubState by mutableStateOf>(emptyList()) + private var composeSelectedDubStatusIndexState by mutableStateOf(0) + private var composeRangeSelectionsState by mutableStateOf>>(emptyList()) + private var composeRangesState by mutableStateOf>(emptyList()) + private var composeSelectedRangeIndexState by mutableStateOf(0) + private var composeTrailersDataState by mutableStateOf>(emptyList()) + private var composeHasTrailersState by mutableStateOf(false) + private var composeResumeWatchingState by mutableStateOf(null) + override fun onDestroyView() { updateUIEvent -= ::updateUI activity?.detachBackPressedCallback(this@ResultFragmentTv.toString()) @@ -96,86 +100,8 @@ class ResultFragmentTv : BaseFragment( viewModel.reloadEpisodes() } - private var currentRecommendations: List = emptyList() - - private fun handleSelection(data: Any) { - when (data) { - is EpisodeRange -> { - viewModel.changeRange(data) - } - - is Int -> { - viewModel.changeSeason(data) - } - - is DubStatus -> { - viewModel.changeDubStatus(data) - } - - is String -> { - setRecommendations(currentRecommendations, data) - } - } - } - - private fun RecyclerView?.select(index: Int) { - (this?.adapter as? SelectAdaptor?)?.select(index, this) - } - - private fun RecyclerView?.update(data: List) { - (this?.adapter as? SelectAdaptor?)?.submitList(data) - this?.isVisible = data.size > 1 - } - - private fun RecyclerView?.setAdapter() { - this?.adapter = SelectAdaptor { data -> - handleSelection(data) - } - } - -// private fun hasNoFocus(): Boolean { -// val focus = activity?.currentFocus -// if (focus == null || !focus.isVisible) return true -// return focus == binding?.resultRoot -// } - - /** - * Force focus any play button. - * Note that this will steal any focus if the episode loading is too slow (unlikely). - */ - private fun focusPlayButton() { - binding?.resultPlayMovieButton?.requestFocus() - binding?.resultPlaySeriesButton?.requestFocus() - binding?.resultResumeSeriesButton?.requestFocus() - } - - private fun setRecommendations(rec: List?, validApiName: String?) { - currentRecommendations = rec ?: emptyList() - val isInvalid = rec.isNullOrEmpty() - binding?.apply { - resultRecommendationsList.isGone = isInvalid - resultRecommendationsHolder.isGone = isInvalid - val matchAgainst = validApiName ?: rec?.firstOrNull()?.apiName - (resultRecommendationsList.adapter as? SearchAdapter)?.submitList(rec?.filter { it.apiName == matchAgainst } - ?: emptyList()) - - rec?.map { it.apiName }?.distinct()?.let { apiNames -> - // very dirty selection - resultRecommendationsFilterSelection.isVisible = apiNames.size > 1 - resultRecommendationsFilterSelection.update(apiNames.map { - txt( - it - ) to it - }) - resultRecommendationsFilterSelection.select(apiNames.indexOf(matchAgainst)) - } ?: run { - resultRecommendationsFilterSelection.isVisible = false - } - } - } - - var loadingDialog: Dialog? = null - var popupDialog: Dialog? = null + private var loadingDialog: Dialog? = null + private var popupDialog: Dialog? = null private fun reloadViewModel(forceReload: Boolean) { if (!viewModel.hasLoaded() || forceReload) { @@ -202,54 +128,6 @@ class ResultFragmentTv : BaseFragment( super.onStop() } - private fun View.fade(turnVisible: Boolean) { - if (turnVisible) { - isVisible = true - } - - this.animate().alpha(if (turnVisible) 0.97f else 0.0f).apply { - duration = 200 - interpolator = DecelerateInterpolator() - setListener(object : Animator.AnimatorListener { - override fun onAnimationStart(animation: Animator) { - } - - override fun onAnimationEnd(animation: Animator) { - this@fade.isVisible = turnVisible - } - - override fun onAnimationCancel(animation: Animator) { - } - - override fun onAnimationRepeat(animation: Animator) { - } - }) - } - this.animate().translationX(if (turnVisible) 0f else if (isRtl()) -100.0f else 100f).apply { - duration = 200 - interpolator = DecelerateInterpolator() - } - } - - private fun toggleEpisodes(show: Boolean) { - binding?.apply { - if (show) { - activity?.attachBackPressedCallback(this@ResultFragmentTv.toString()) { - toggleEpisodes(false) - } - } else { - activity?.detachBackPressedCallback(this@ResultFragmentTv.toString()) - } - episodesShadow.fade(show) - episodeHolderTv.fade(show) - if (episodesShadow.isRtl()) { - episodesShadowBackground.scaleX = -1f - } else { - episodesShadowBackground.scaleX = 1f - } - } - } - override fun fixLayout(view: View) { fixSystemBarsPadding(view, padTop = false) } @@ -261,7 +139,7 @@ class ResultFragmentTv : BaseFragment( activity?.window?.decorView?.clearFocus() activity?.loadCache() hideKeyboard() - if (storedData.restart || !viewModel.hasLoaded()) + if (storedData.restart || !viewModel.hasLoaded()) { viewModel.load( activity, storedData.url, @@ -270,436 +148,244 @@ class ResultFragmentTv : BaseFragment( storedData.dubStatus, storedData.start ) + } // ===== ===== ===== - var comingSoon = false - - binding.apply { - //episodesShadow.rotationX = 180.0f//if(episodesShadow.isRtl()) 180.0f else 0.0f - - // parallax on background - resultFinishLoading.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { view, _, scrollY, _, oldScrollY -> - backgroundPosterHolder.translationY = -scrollY.toFloat() * 0.8f - }) - - redirectToPlay.setOnFocusChangeListener { _, hasFocus -> - if (!hasFocus) return@setOnFocusChangeListener - toggleEpisodes(false) - - binding.apply { - val views = listOf( - resultPlayMovieButton, - resultPlaySeriesButton, - resultResumeSeriesButton, - resultPlayTrailerButton, - resultBookmarkButton, - resultFavoriteButton, - resultSubscribeButton, - resultSearchButton - ) - for (requestView in views) { - if (!requestView.isVisible) continue - if (requestView.requestFocus()) break - } - } - } - - redirectToEpisodes.setOnFocusChangeListener { _, hasFocus -> - if (!hasFocus) return@setOnFocusChangeListener - toggleEpisodes(true) - binding.apply { - val views = listOf( - resultDubSelection, - resultSeasonSelection, - resultRangeSelection, - resultEpisodes, - resultPlayTrailerButton, - ) - for (requestView in views) { - if (!requestView.isShown) continue - if (requestView.requestFocus()) break // View.FOCUS_RIGHT - } - } - } - - mapOf( - resultPlayMovieButton to resultPlayMovieText, - resultPlaySeriesButton to resultPlaySeriesText, - resultResumeSeriesButton to resultResumeSeriesText, - resultPlayTrailerButton to resultPlayTrailerText, - resultBookmarkButton to resultBookmarkText, - resultFavoriteButton to resultFavoriteText, - resultSubscribeButton to resultSubscribeText, - resultSearchButton to resultSearchText, - resultEpisodesShowButton to resultEpisodesShowText - ).forEach { (button, text) -> - - button.setOnFocusChangeListener { view, hasFocus -> - if (!hasFocus) { - text.isSelected = false - if (view.id == R.id.result_episodes_show_button) toggleEpisodes(false) - return@setOnFocusChangeListener - } - - text.isSelected = true - if (button.tag == context?.getString(R.string.tv_no_focus_tag)) { - resultFinishLoading.scrollTo(0, 0) - } - when (button.id) { - R.id.result_episodes_show_button -> { - toggleEpisodes(true) - } - - else -> { - toggleEpisodes(false) - } - } - } - } - resultEpisodesShowButton.setOnClickListener { - // toggle, to make it more touch accessible just in case someone thinks that a - // tv layout is better but is using a touch device - toggleEpisodes(!episodeHolderTv.isVisible) - } + setupComposeView(binding, storedData) + setupViewModelObservers(binding, storedData) + } - resultEpisodes.setLinearListLayout( - isHorizontal = false, - nextUp = FOCUS_SELF, - nextDown = FOCUS_SELF, - nextRight = FOCUS_SELF, - ) - resultDubSelection.setLinearListLayout( - isHorizontal = false, - nextUp = FOCUS_SELF, - nextDown = FOCUS_SELF, - ) - resultRangeSelection.setLinearListLayout( - isHorizontal = false, - nextUp = FOCUS_SELF, - nextDown = FOCUS_SELF, - ) - resultSeasonSelection.setLinearListLayout( - isHorizontal = false, - nextUp = FOCUS_SELF, - nextDown = FOCUS_SELF, + private fun handlePlayClick(storedData: ResultFragment.StoredData) { + val resume = composeResumeWatchingState + if (resume != null) { + viewModel.handleAction( + EpisodeClickEvent( + storedData.playerAction, + resume.result + ) ) - - /*.layoutManager = - LinearListLayout(resultEpisodes.context, resultEpisodes.isRtl()).apply { - setVertical() - }*/ - - resultReloadConnectionerror.setOnClickListener { - viewModel.load( - activity, - storedData.url, - storedData.apiName, - storedData.showFillers, - storedData.dubStatus, - storedData.start + } else { + val ep = composeEpisodesState.firstOrNull() + if (ep != null) { + viewModel.handleAction( + EpisodeClickEvent( + storedData.playerAction, + ep + ) ) - - } - - resultMetaSite.isFocusable = false - - resultSeasonSelection.setAdapter() - resultRangeSelection.setAdapter() - resultDubSelection.setAdapter() - resultRecommendationsFilterSelection.setAdapter() - - resultCastItems.setOnFocusChangeListener { _, hasFocus -> - // Always escape focus - if (hasFocus) binding.resultBookmarkButton.requestFocus() - } - //resultBack.setOnClickListener { - // activity?.popCurrentPage() - //} - - resultRecommendationsList.spanCount = 8 - resultRecommendationsList.setRecycledViewPool(SearchAdapter.sharedPool) - resultRecommendationsList.adapter = - SearchAdapter( - resultRecommendationsList, - ) { callback -> - if (callback.action == SEARCH_ACTION_FOCUSED) { - toggleEpisodes(false) - } else SearchHelper.handleSearchClickCallback(callback) + } else { + (viewModel.movie.value as? Resource.Success)?.value?.let { (_, movieEp) -> + viewModel.handleAction( + EpisodeClickEvent(ACTION_CLICK_DEFAULT, movieEp) + ) } + } + } + } - resultEpisodes.setRecycledViewPool(EpisodeAdapter.sharedPool) - resultEpisodes.adapter = - EpisodeAdapter( - false, - { episodeClick -> - viewModel.handleAction(episodeClick) - }, - { downloadClickEvent -> - DownloadButtonSetup.handleDownloadClick(downloadClickEvent) - } + private fun handlePlayLongClick() { + val resume = composeResumeWatchingState + val ep = resume?.result ?: composeEpisodesState.firstOrNull() ?: (viewModel.movie.value as? Resource.Success)?.value?.second + if (ep != null) { + viewModel.handleAction( + EpisodeClickEvent( + ACTION_SHOW_OPTIONS, + ep ) + ) + } + } - resultCastItems.layoutManager = object : LinearListLayout(root.context) { - override fun onRequestChildFocus( - parent: RecyclerView, - state: RecyclerView.State, - child: View, - focused: View? - ): Boolean { - // Make the cast always focus the first visible item when focused - // from somewhere else. Otherwise it jumps to the last item. - return if (parent.focusedChild == null) { - scrollToPosition(this.findFirstCompletelyVisibleItemPosition()) - true - } else { - super.onRequestChildFocus(parent, state, child, focused) - } - } - }.apply { setHorizontal() } - - val aboveCast = listOf( - binding.resultEpisodesShow, - binding.resultBookmark, - binding.resultFavorite, - binding.resultSubscribe, - ).firstOrNull { it.isVisible } - - resultCastItems.setRecycledViewPool(ActorAdaptor.sharedPool) - resultCastItems.adapter = ActorAdaptor(aboveCast?.id) { - toggleEpisodes(false) - } + private fun handleTrailerClick() { + val trailersLinks = viewModel.trailers.value ?: emptyList() + val extractedTrailerLinks = trailersLinks.flatMap { it.mirros } + .map { (extractedTrailerLink, _) -> extractedTrailerLink } + if (extractedTrailerLinks.isNotEmpty()) { + activity.navigate( + R.id.global_to_navigation_player, + GeneratorPlayer.newInstance( + ExtractorLinkGenerator( + extractedTrailerLinks, + emptyList() + ), + 0 + ) + ) + } + } - if (isLayout(EMULATOR)) { - episodesShadow.setOnClickListener { - toggleEpisodes(false) - } - } + private fun handleAddToListClick() { + val curStatus = composeWatchStatusState + activity?.showBottomDialog( + WatchType.entries.map { getString(it.stringRes) }.toList(), + curStatus.ordinal, + getString(R.string.action_add_to_bookmarks), + showApply = false, + {} + ) { + viewModel.updateWatchStatus(WatchType.entries[it], context) } + } - observeNullable(viewModel.resumeWatching) { resume -> - binding.apply { - if (resume == null) { - return@observeNullable - } + private fun handleLikeClick() { + viewModel.toggleFavoriteStatus(context) { newStatus: Boolean? -> + if (newStatus == null) return@toggleFavoriteStatus + val message = if (newStatus) R.string.favorite_added else R.string.favorite_removed + val name = (viewModel.page.value as? Resource.Success)?.value?.title + ?: txt(R.string.no_data).asStringNull(context) ?: "" + CommonActivity.showToast(txt(message, name), Toast.LENGTH_SHORT) + } + } - resultResumeSeries.isVisible = true - resultPlayMovie.isVisible = false - resultPlaySeries.isVisible = false - - // show progress no matter if series or movie - resume.progress?.let { progress -> - resultResumeSeriesTitle.apply { - isVisible = !resume.isMovie - text = - if (resume.isMovie) null else context?.getNameFull( - resume.result.name, - resume.result.episode, - resume.result.season - ) - } - resultResumeSeriesProgressText.setText(progress.progressLeft) - resultResumeSeriesProgress.apply { - isVisible = true - this.max = progress.maxProgress - this.progress = progress.progress + private fun setupComposeView(binding: FragmentResultTvBinding, storedData: ResultFragment.StoredData) { + binding.resultComposeView.setContent { + MovieDetailsTheme { + val page = composePageState + if (page is Resource.Success) { + val d = page.value + val title = d.titleText.asStringNull(context) ?: d.title + val provider = d.apiName.asStringNull(context) ?: storedData.apiName + val plot = d.plotText.asStringNull(context) ?: "" + val year = d.yearText?.asStringNull(context) + val duration = d.durationText?.asStringNull(context) + val rating = d.ratingText?.asStringNull(context) + val contentRating = d.contentRatingText?.asStringNull(context) + val genres = d.tags + val castNames = d.actorsText?.asStringNull(context) + ?.replace(Regex("""^Cast:\s*""", RegexOption.IGNORE_CASE), "") + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotBlank() } + ?: emptyList() + val actors = composeActorsState.ifEmpty { + d.actors ?: castNames.map { ActorData(com.lagradost.cloudstream3.Actor(it)) } } - resultResumeProgressHolder.isVisible = true - } ?: run { - resultResumeProgressHolder.isVisible = false - } - - focusPlayButton() - // Stops last button right focus if it is a movie - if (resume.isMovie) - resultSearchButton.nextFocusRightId = R.id.result_search_Button - - resultResumeSeriesText.text = - when { - resume.isMovie -> context?.getString(R.string.resume) - resume.result.season != null -> - "${getString(R.string.season_short)}${resume.result.season}:${ - getString( - R.string.episode_short + val ongoingInfo = d.onGoingText?.asStringNull(context) + val isOngoing = d.isOngoing ?: (d.showStatus?.let { it == com.lagradost.cloudstream3.ShowStatus.Ongoing } == true) + + MovieDetailsComposeScreen( + title = title, + providerName = provider, + backdropUrl = d.posterBackgroundImage ?: d.posterImage, + posterUrl = d.posterImage, + logoUrl = d.logoUrl, + matchScore = rating, + releaseYear = year, + seasonsCount = duration, + maturityRating = contentRating, + advisories = null, + statusText = ongoingInfo, + isOngoing = isOngoing, + nextAiringUnixTime = d.nextAiringUnixTime, + nextAiringEpisode = d.nextAiringEpisode?.asStringNull(context), + nextAiringDate = d.nextAiringDate?.asStringNull(context), + synopsis = plot, + genres = genres, + dynamicActors = actors, + dynamicEpisodes = composeEpisodesState, + dynamicRecommendations = composeRecommendationsState, + dynamicSeasons = composeSeasonsState, + selectedSeasonIndex = composeSelectedSeasonIndexState, + dynamicDubs = composeDubSubState, + selectedDubIndex = composeSelectedDubStatusIndexState, + onDubSelect = { idx -> + composeDubSubSelectionsState.getOrNull(idx)?.second?.let { dub -> + viewModel.changeDubStatus(dub) + } + }, + dynamicRanges = composeRangesState, + selectedRangeIndex = composeSelectedRangeIndexState, + onRangeSelect = { idx -> + composeRangeSelectionsState.getOrNull(idx)?.second?.let { range -> + viewModel.changeRange(range) + } + }, + dynamicTrailers = composeTrailersDataState, + isInWatchList = composeWatchStatusState != WatchType.NONE, + isFavorite = composeFavoriteStatusState, + hasTrailers = composeHasTrailersState, + resumeStatus = composeResumeWatchingState, + isMovie = composeEpisodesState.isEmpty() || composeResumeWatchingState?.isMovie == true, + onPlayClick = { handlePlayClick(storedData) }, + onPlayLongClick = { handlePlayLongClick() }, + onEpisodeClick = { ep -> + viewModel.handleAction( + EpisodeClickEvent( + storedData.playerAction, + ep ) - }${resume.result.episode}" - - else -> "${getString(R.string.episode)} ${resume.result.episode}" - } - - resultResumeSeriesButton.setOnClickListener { - viewModel.handleAction( - EpisodeClickEvent( - storedData.playerAction, //?: ACTION_PLAY_EPISODE_IN_PLAYER, - resume.result - ) - ) - } - - resultResumeSeriesButton.setOnLongClickListener { - viewModel.handleAction( - EpisodeClickEvent(ACTION_SHOW_OPTIONS, resume.result) + ) + }, + onEpisodeLongClick = { ep -> + viewModel.handleAction( + EpisodeClickEvent( + ACTION_SHOW_OPTIONS, + ep + ) + ) + }, + onSeasonSelect = { idx -> + val seasonNumber = composeRawSeasonsState.getOrNull(idx)?.second ?: (idx + 1) + viewModel.changeSeason(seasonNumber) + }, + onAddToListClick = { handleAddToListClick() }, + onLikeClick = { handleLikeClick() }, + onTrailerClick = { handleTrailerClick() }, + onSearchClick = { + QuickSearchFragment.pushSearch(activity, title) + }, + onActorClick = { actorName -> + QuickSearchFragment.pushSearch(activity, actorName) + }, + onRecommendationClick = { rec -> + SearchHelper.handleSearchClickCallback( + SearchClickCallback( + SEARCH_ACTION_LOAD, + binding.root, + 0, + rec + ) + ) + }, + onCloseClick = { + activity?.popCurrentPage() + } ) - return@setOnLongClickListener true } - } } + } - observe(viewModel.trailers) { trailersLinks -> - context?.updateHasTrailers() - if (!LoadResponse.isTrailersEnabled) return@observe - val extractedTrailerLinks = trailersLinks.flatMap { it.mirros } - .map { (extractedTrailerLink, _) -> extractedTrailerLink } - binding.apply { - resultPlayTrailer.isGone = extractedTrailerLinks.isEmpty() - resultPlayTrailerButton.setOnClickListener { - if (extractedTrailerLinks.isEmpty()) return@setOnClickListener - activity.navigate( - R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - ExtractorLinkGenerator( - extractedTrailerLinks, - emptyList() - ), 0 - ) - ) - } - } + private fun setupViewModelObservers(binding: FragmentResultTvBinding, storedData: ResultFragment.StoredData) { + observeNullable(viewModel.resumeWatching) { resume -> + composeResumeWatchingState = resume } - observe(viewModel.watchStatus) { watchType -> - binding.apply { - resultBookmarkText.setText(watchType.stringRes) - - resultBookmarkButton.apply { - val drawable = if (watchType.stringRes == R.string.type_none) { - R.drawable.outline_bookmark_add_24 - } else R.drawable.ic_baseline_bookmark_24 - setIconResource(drawable) - - setOnClickListener { view -> - activity?.showBottomDialog( - WatchType.entries.map { view.context.getString(it.stringRes) }.toList(), - watchType.ordinal, - view.context.getString(R.string.action_add_to_bookmarks), - showApply = false, - {}) { - viewModel.updateWatchStatus(WatchType.entries[it], context) - } - } - } + observe(viewModel.trailers) { trailersLinks -> + val trailerItems = trailersLinks.flatMap { it.mirros }.mapNotNull { (extractedTrailerLink, _) -> + MovieTrailerData( + title = extractedTrailerLink.name.ifBlank { getString(R.string.play_trailer) }, + runtime = "", + rawTrailer = extractedTrailerLink + ) } + composeTrailersDataState = trailerItems + composeHasTrailersState = trailerItems.isNotEmpty() } - observeNullable(viewModel.favoriteStatus) { isFavorite -> - binding.resultFavorite.isVisible = isFavorite != null - binding.resultFavoriteButton.apply { - if (isFavorite == null) return@observeNullable - - val drawable = if (isFavorite) { - R.drawable.ic_baseline_favorite_24 - } else R.drawable.ic_baseline_favorite_border_24 - setIconResource(drawable) - - setOnClickListener { - viewModel.toggleFavoriteStatus(context) { newStatus: Boolean? -> - if (newStatus == null) return@toggleFavoriteStatus - - val message = if (newStatus) { - R.string.favorite_added - } else R.string.favorite_removed - - val name = (viewModel.page.value as? Resource.Success)?.value?.title - ?: txt(R.string.no_data) - .asStringNull(context) ?: "" - CommonActivity.showToast( - txt( - message, - name - ), Toast.LENGTH_SHORT - ) - } - } - } - - binding.resultFavoriteText.apply { - val text = if (isFavorite == true) { - R.string.unfavorite - } else R.string.favorite - setText(text) - } + observeNullable(viewModel.favoriteStatus) { isFav -> + composeFavoriteStatusState = isFav == true } - observeNullable(viewModel.subscribeStatus) { isSubscribed -> - binding.resultSubscribe.isVisible = isSubscribed != null && isLayout(EMULATOR) - binding.resultSubscribeButton.apply { - if (isSubscribed == null) return@observeNullable - - val drawable = if (isSubscribed) { - R.drawable.ic_baseline_notifications_active_24 - } else R.drawable.baseline_notifications_none_24 - setIconResource(drawable) - - setOnClickListener { - viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? -> - if (newStatus == null) return@toggleSubscriptionStatus - - val message = if (newStatus) { - // Kinda icky to have this here, but it works. - SubscriptionWorkManager.enqueuePeriodicWork(context) - R.string.subscription_new - } else R.string.subscription_deleted - - val name = (viewModel.page.value as? Resource.Success)?.value?.title - ?: txt(R.string.no_data) - .asStringNull(context) ?: "" - CommonActivity.showToast( - txt( - message, - name - ), Toast.LENGTH_SHORT - ) - } - } - - binding.resultSubscribeText.apply { - val text = if (isSubscribed) { - R.string.action_unsubscribe - } else R.string.action_subscribe - setText(text) - } - } + observe(viewModel.watchStatus) { watchType -> + composeWatchStatusState = watchType } - observeNullable(viewModel.movie) { data -> - if (data == null) { - return@observeNullable - } - - binding.apply { - (data as? Resource.Success)?.value?.let { (_, ep) -> - resultPlayMovieButton.setOnClickListener { - viewModel.handleAction( - EpisodeClickEvent(ACTION_CLICK_DEFAULT, ep) - ) - } - resultPlayMovieButton.setOnLongClickListener { - viewModel.handleAction( - EpisodeClickEvent(ACTION_SHOW_OPTIONS, ep) - ) - return@setOnLongClickListener true - } - - resultPlayMovie.isVisible = !comingSoon && resultResumeSeries.isGone - if (comingSoon) { - resultBookmarkButton.requestFocus() - } else resultPlayMovieButton.requestFocus() - - // Stops last button right focus - resultSearchButton.nextFocusRightId = R.id.result_search_Button - } - } - } + observePopupsAndLoading() + observeSelectionsAndContent(binding, storedData) + } + private fun observePopupsAndLoading() { observeNullable(viewModel.selectPopup) { popup -> if (popup == null) { popupDialog?.dismissSafe(activity) @@ -708,7 +394,6 @@ class ResultFragmentTv : BaseFragment( } popupDialog?.dismissSafe(activity) - popupDialog = activity?.let { act -> val options = popup.getOptions(act) val title = popup.getTitle(act) @@ -758,207 +443,73 @@ class ResultFragmentTv : BaseFragment( } } } + } - - observeNullable(viewModel.episodesCountText) { count -> - binding.resultEpisodesText.setText(count) + private fun observeSelectionsAndContent(binding: FragmentResultTvBinding, storedData: ResultFragment.StoredData) { + observe(viewModel.selectedSeasonIndex) { selected -> + composeSelectedSeasonIndexState = selected } - observe(viewModel.selectedRangeIndex) { selected -> - binding.resultRangeSelection.select(selected) + observe(viewModel.seasonSelections) { + composeRawSeasonsState = it + composeSeasonsState = it.map { s -> s.first?.asStringNull(context) ?: "" } } - observe(viewModel.selectedSeasonIndex) { selected -> - binding.resultSeasonSelection.select(selected) + + observe(viewModel.dubSubSelections) { + composeDubSubSelectionsState = it + composeDubSubState = it.map { d -> d.first?.asStringNull(context) ?: "" } } - observe(viewModel.selectedDubStatusIndex) { selected -> - binding.resultDubSelection.select(selected) + + observe(viewModel.selectedDubStatusIndex) { + composeSelectedDubStatusIndexState = it } + observe(viewModel.rangeSelections) { - binding.resultRangeSelection.update(it) - } - observe(viewModel.dubSubSelections) { - binding.resultDubSelection.update(it) + composeRangeSelectionsState = it + composeRangesState = it.map { r -> r.first?.asStringNull(context) ?: "" } } - observe(viewModel.seasonSelections) { - binding.resultSeasonSelection.update(it) - } - observe(viewModel.recommendations) { recommendations -> - setRecommendations(recommendations, null) + + observe(viewModel.selectedRangeIndex) { + composeSelectedRangeIndexState = it } - if (isLayout(TV)) { - observe(viewModel.episodeSynopsis) { description -> - context?.let { ctx -> - val builder: AlertDialog.Builder = - AlertDialog.Builder(ctx, R.style.AlertDialogCustom) - builder.setMessage(description.html()) - .setTitle(R.string.synopsis) - .setOnDismissListener { - viewModel.releaseEpisodeSynopsis() - } - .show() - } - } + observe(viewModel.recommendations) { recommendations -> + composeRecommendationsState = recommendations } - // Used to request focus the first time the episodes are loaded. - var hasLoadedEpisodesOnce = false observeNullable(viewModel.episodes) { episodes -> if (episodes == null) return@observeNullable - binding.apply { - if (comingSoon) resultBookmarkButton.requestFocus() - - // resultEpisodeLoading.isVisible = episodes is Resource.Loading - if (episodes is Resource.Success) { - val lastWatchedIndex = episodes.value.indexOfLast { ep -> - ep.getWatchProgress() >= NEXT_WATCH_EPISODE_PERCENTAGE.toFloat() / 100.0f || ep.videoWatchState == VideoWatchState.Watched - } - - val firstUnwatched = - episodes.value.getOrElse(lastWatchedIndex + 1) { episodes.value.firstOrNull() } - - if (firstUnwatched != null) { - resultPlaySeriesText.text = - when { - firstUnwatched.season != null -> - "${getString(R.string.season_short)}${firstUnwatched.season}:${ - getString( - R.string.episode_short - ) - }${firstUnwatched.episode}" - - else -> "${getString(R.string.episode)} ${firstUnwatched.episode}" - } - resultPlaySeriesButton.setOnClickListener { - viewModel.handleAction( - EpisodeClickEvent( - ACTION_CLICK_DEFAULT, - firstUnwatched - ) - ) - } - resultPlaySeriesButton.setOnLongClickListener { - viewModel.handleAction( - EpisodeClickEvent(ACTION_SHOW_OPTIONS, firstUnwatched) - ) - return@setOnLongClickListener true - } - if (!hasLoadedEpisodesOnce) { - hasLoadedEpisodesOnce = true - resultPlaySeries.isVisible = resultResumeSeries.isGone && !comingSoon - resultEpisodesShow.isVisible = true && !comingSoon - resultPlaySeriesButton.requestFocus() - } - } - - - (resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value) - } + if (episodes is Resource.Success) { + composeEpisodesState = episodes.value } } observeNullable(viewModel.page) { data -> if (data == null) return@observeNullable + composePageState = data binding.apply { when (data) { is Resource.Success -> { val d = data.value - resultVpn.setText(d.vpnText) - resultInfo.setText(d.metaText) - resultNoEpisodes.setText(d.noEpisodesFoundText) - resultTitle.setText(d.titleText) - resultMetaSite.setText(d.apiName) - resultMetaType.setText(d.typeText) - resultMetaYear.setText(d.yearText) - resultMetaDuration.setText(d.durationText) - resultMetaRating.setText(d.ratingText) - resultMetaStatus.setText(d.onGoingText) - resultMetaContentRating.setText(d.contentRatingText) - resultNextAiring.setText(d.nextAiringEpisode) - resultNextAiringTime.setText(d.nextAiringDate) - resultPoster.loadImage(d.posterImage, headers = d.posterHeaders) - - var isExpanded = false - resultDescription.apply { - setTextHtml(d.plotText) - setOnClickListener { - if (isLayout(EMULATOR)) { - isExpanded = !isExpanded - maxLines = if (isExpanded) { - Integer.MAX_VALUE - } else 10 - } else { - context?.let { ctx -> - val builder: AlertDialog.Builder = - AlertDialog.Builder(ctx, R.style.AlertDialogCustom) - builder.setMessage(d.plotText.asString(ctx).html()) - .setTitle(d.plotHeaderText.asString(ctx)) - .show() - } - } - } - } - - val error = listOf( - R.drawable.profile_bg_dark_blue, - R.drawable.profile_bg_blue, - R.drawable.profile_bg_orange, - R.drawable.profile_bg_pink, - R.drawable.profile_bg_purple, - R.drawable.profile_bg_red, - R.drawable.profile_bg_teal - ).random() - - backgroundPoster.loadImage(d.posterBackgroundImage, headers = d.posterHeaders) { - error { getImageFromDrawable(context ?: return@error null, error) } - } - - bindLogo( - url = d.logoUrl, - headers = d.posterHeaders, - titleView = resultTitle, - logoView = backgroundPosterWatermarkBadgeHolder - ) - - comingSoon = d.comingSoon - resultTvComingSoon.isVisible = d.comingSoon - - populateChips(resultTag, d.tags) - val prefs = - androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context) - val showCast = prefs.getBoolean( - root.context.getString(R.string.show_cast_in_details_key), - true - ) - - resultCastText.setText(if (showCast) d.actorsText else null) - resultCastItems.isGone = !showCast || d.actors.isNullOrEmpty() - (resultCastItems.adapter as? ActorAdaptor)?.submitList(if (showCast) d.actors else emptyList()) - - if (d.contentRatingText == null) { - // If there is no rating to display, we don't want an empty gap - resultMetaContentRating.width = 0 - } - - resultSearchButton.setOnClickListener { - QuickSearchFragment.pushSearch(activity, d.title) - } + composeActorsState = d.actors ?: emptyList() + resultComposeView.isVisible = true + resultLoading.isGone = true + resultLoadingError.isGone = true } - is Resource.Loading -> {} + is Resource.Loading -> { + resultComposeView.isGone = true + resultLoading.isVisible = true + resultLoadingError.isGone = true + } is Resource.Failure -> { - resultErrorText.text = - storedData.url.plus("\n") + data.errorString + resultComposeView.isGone = true + resultLoading.isGone = true + resultLoadingError.isVisible = true + resultErrorText.text = storedData.url.plus("\n") + data.errorString } } - - resultFinishLoading.isVisible = data is Resource.Success - - resultLoading.isVisible = data is Resource.Loading - - resultLoadingError.isVisible = data is Resource.Failure - //resultReloadConnectionOpenInBrowser.isVisible = data is Resource.Failure } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index c519e0de230..9b77e7546b9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -186,12 +186,15 @@ data class ResultData( val metaText: UiText?, val durationText: UiText?, val onGoingText: UiText?, + val showStatus: ShowStatus? = null, + val isOngoing: Boolean? = null, val noEpisodesFoundText: UiText?, val titleText: UiText, val typeText: UiText, val yearText: UiText?, val nextAiringDate: UiText?, val nextAiringEpisode: UiText?, + val nextAiringUnixTime: Long? = null, val plotHeaderText: UiText, val posterHeaders: Map? = null, ) @@ -236,10 +239,12 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { var nextAiringEpisode: UiText? = null var nextAiringDate: UiText? = null + var nextAiringUnixTime: Long? = null if (this is EpisodeResponse) { val airing = this.nextAiring if (airing != null && airing.unixTime > unixTime) { + nextAiringUnixTime = airing.unixTime val seconds = airing.unixTime - unixTime val days = TimeUnit.SECONDS.toDays(seconds) val hours: Long = TimeUnit.SECONDS.toHours(seconds) - days * 24 @@ -287,6 +292,7 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { ), nextAiringDate = nextAiringDate, nextAiringEpisode = nextAiringEpisode, + nextAiringUnixTime = nextAiringUnixTime, posterImage = posterUrl ?: backgroundPosterUrl, posterHeaders = posterHeaders, posterBackgroundImage = backgroundPosterUrl ?: posterUrl, @@ -353,6 +359,8 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { } ) } else null, + showStatus = if (this is EpisodeResponse) this.showStatus else null, + isOngoing = if (this is EpisodeResponse) this.showStatus == ShowStatus.Ongoing else null, noEpisodesFoundText = if ((this is TvSeriesLoadResponse && this.episodes.isEmpty()) || (this is AnimeLoadResponse && !this.episodes.any { it.value.isNotEmpty() })) txt( R.string.no_episodes_found diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/MovieDetailsComposeScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/MovieDetailsComposeScreen.kt new file mode 100644 index 00000000000..f5002448200 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/MovieDetailsComposeScreen.kt @@ -0,0 +1,512 @@ +package com.lagradost.cloudstream3.ui.result.compose + +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.BringIntoViewSpec +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.ui.result.ResumeWatchingStatus +import com.lagradost.cloudstream3.ui.result.compose.components.EpisodeRowItem +import com.lagradost.cloudstream3.ui.result.compose.components.MovieCardItem +import com.lagradost.cloudstream3.ui.result.compose.components.MovieCardType +import com.lagradost.cloudstream3.ui.result.compose.model.MovieRecommendationRow +import com.lagradost.cloudstream3.ui.result.compose.model.MovieTrailerData +import com.lagradost.cloudstream3.ui.result.compose.model.getPlayButtonText +import com.lagradost.cloudstream3.ui.result.compose.model.resolveAiringSchedule +import com.lagradost.cloudstream3.ui.result.compose.sections.AboutSection +import com.lagradost.cloudstream3.ui.result.compose.sections.CastAndCrewSection +import com.lagradost.cloudstream3.ui.result.compose.sections.EpisodesHeaderSection +import com.lagradost.cloudstream3.ui.result.compose.sections.HeroBannerSection +import com.lagradost.cloudstream3.ui.result.compose.sections.MovieInfoSynopsisSection +import com.lagradost.cloudstream3.ui.result.compose.sections.RecommendationRowView +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsColors +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsTheme + +private fun LazyListScope.episodesSection( + episodesToDisplay: List, + seasonOptions: List, + selectedSeasonText: String, + onSeasonSelect: ((Int) -> Unit)?, + onSeasonTextChange: (String) -> Unit, + dubOptions: List, + selectedDubText: String, + onDubSelect: ((Int) -> Unit)?, + onDubTextChange: (String) -> Unit, + rangeOptions: List, + selectedRangeText: String, + onRangeSelect: ((Int) -> Unit)?, + onRangeTextChange: (String) -> Unit, + airingSchedule: com.lagradost.cloudstream3.ui.result.compose.model.AiringScheduleUiState?, + onEpisodeClick: ((ResultEpisode) -> Unit)?, + onEpisodeLongClick: ((ResultEpisode) -> Unit)?, + showToast: (String) -> Unit +) { + if (episodesToDisplay.isEmpty()) return + + item(key = "episodes_header", contentType = "episodes_header") { + EpisodesHeaderSection( + seasonOptions = seasonOptions, + selectedSeasonText = selectedSeasonText, + onSeasonSelect = onSeasonSelect, + onSeasonTextChange = onSeasonTextChange, + dubOptions = dubOptions, + selectedDubText = selectedDubText, + onDubSelect = onDubSelect, + onDubTextChange = onDubTextChange, + rangeOptions = rangeOptions, + selectedRangeText = selectedRangeText, + onRangeSelect = onRangeSelect, + onRangeTextChange = onRangeTextChange, + airingSchedule = airingSchedule + ) + } + + items( + items = episodesToDisplay, + key = { ep -> "episode_${ep.id}" }, + contentType = { "episode_row" } + ) { ep -> + val onEpClick = remember(ep) { + { + if (onEpisodeClick != null) onEpisodeClick(ep) + else showToast("Playing ${ep.headerName}: ${ep.name}") + } + } + val onEpLongClick = remember(ep) { + if (onEpisodeLongClick != null) { + { onEpisodeLongClick(ep) } + } else null + } + EpisodeRowItem( + episode = ep, + onClick = onEpClick, + onLongClick = onEpLongClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = MovieDetailsTheme.dimens.spacing2Xl, vertical = 6.dp) + ) + } +} + +private fun LazyListScope.recommendationsSection( + chunkedRecommendations: List, + dynamicRecommendations: List?, + onRecommendationClick: ((SearchResponse) -> Unit)?, + showToast: (String) -> Unit, + colors: MovieDetailsColors +) { + if (chunkedRecommendations.isEmpty()) return + + item(key = "recommendations_header", contentType = "recommendations_header") { + Text( + text = stringResource(id = R.string.more_like_this), + style = MovieDetailsTheme.typography.boldTitle2, + fontSize = 22.sp, + color = colors.textPrimary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = MovieDetailsTheme.dimens.spacing2Xl) + .padding(top = MovieDetailsTheme.dimens.spacing3Xl, bottom = MovieDetailsTheme.dimens.spacingL) + ) + } + + items( + items = chunkedRecommendations, + key = { row -> "rec_row_${row.rowIndex}" }, + contentType = { "recommendation_row" } + ) { row -> + RecommendationRowView( + row = row, + onCardClick = { cardItem -> + val rec = dynamicRecommendations?.find { it.name == cardItem.title } + if (rec != null && onRecommendationClick != null) { + onRecommendationClick(rec) + } else { + showToast("Opened recommendation: ${cardItem.title}") + } + } + ) + } +} + +private fun LazyListScope.castSection( + dynamicActors: List?, + onActorClick: ((String) -> Unit)? +) { + if (dynamicActors.isNullOrEmpty()) return + + item(key = "cast_and_crew_section", contentType = "cast_section") { + CastAndCrewSection( + actors = dynamicActors, + onActorClick = onActorClick, + onActorLongClick = onActorClick + ) + } +} + +private fun LazyListScope.aboutSection( + title: String, + creator: String?, + castList: List, + writers: List, + genres: List, + moodTags: List, + maturityRating: String?, + advisories: String? +) { + val hasAboutContent = !creator.isNullOrBlank() || castList.isNotEmpty() || + writers.isNotEmpty() || genres.isNotEmpty() || moodTags.isNotEmpty() || + !maturityRating.isNullOrBlank() + if (!hasAboutContent) return + + item(key = "about_section", contentType = "about_section") { + AboutSection( + title = title, + creator = creator, + castList = castList, + writers = writers, + genres = genres, + moodTags = moodTags, + maturityRating = maturityRating, + advisories = advisories + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MovieDetailsComposeScreen( + modifier: Modifier = Modifier, + title: String = "", + providerName: String? = null, + backdropUrl: String? = null, + posterUrl: String? = null, + logoUrl: String? = null, + matchScore: String? = null, + releaseYear: String? = null, + seasonsCount: String? = null, + quality: String? = null, + maturityRating: String? = null, + advisories: String? = null, + top10RankText: String? = null, + synopsis: String = "", + cast: List = emptyList(), + genres: List = emptyList(), + moodTags: List = emptyList(), + creator: String? = null, + writers: List = emptyList(), + dynamicEpisodes: List? = null, + dynamicRecommendations: List? = null, + dynamicActors: List? = null, + dynamicSeasons: List? = null, + dynamicTrailers: List? = null, + resumeStatus: ResumeWatchingStatus? = null, + isMovie: Boolean = true, + selectedSeasonIndex: Int = 0, + dynamicDubs: List? = null, + selectedDubIndex: Int = 0, + onDubSelect: ((Int) -> Unit)? = null, + dynamicRanges: List? = null, + selectedRangeIndex: Int = 0, + onRangeSelect: ((Int) -> Unit)? = null, + onPlayLongClick: (() -> Unit)? = null, + onEpisodeLongClick: ((ResultEpisode) -> Unit)? = null, + statusText: String? = null, + isOngoing: Boolean = false, + nextAiringUnixTime: Long? = null, + nextAiringEpisode: String? = null, + nextAiringDate: String? = null, + isInWatchList: Boolean = false, + isFavorite: Boolean = false, + hasTrailers: Boolean = false, + onPlayClick: () -> Unit = {}, + onEpisodeClick: ((ResultEpisode) -> Unit)? = null, + onSeasonSelect: ((Int) -> Unit)? = null, + onAddToListClick: () -> Unit = {}, + onLikeClick: () -> Unit = {}, + onTrailerClick: () -> Unit = {}, + onSearchClick: (() -> Unit)? = null, + onActorClick: ((String) -> Unit)? = null, + onRecommendationClick: ((SearchResponse) -> Unit)? = null, + onCloseClick: () -> Unit = {} +) { + val context = LocalContext.current + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp.dp + val heroHeight = remember(screenHeight) { (screenHeight * 0.65f).coerceAtLeast(380.dp) } + + val colors = MovieDetailsTheme.colors + val lazyListState = rememberLazyListState() + + val playInteractionSource = remember { MutableInteractionSource() } + val inMyListInteractionSource = remember { MutableInteractionSource() } + val likeInteractionSource = remember { MutableInteractionSource() } + val trailerInteractionSource = remember { MutableInteractionSource() } + val searchInteractionSource = remember { MutableInteractionSource() } + + val playButtonFocusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + lazyListState.scrollToItem(0) + playButtonFocusRequester.requestFocus() + } + + val seasonOptions = remember(dynamicSeasons) { + dynamicSeasons ?: emptyList() + } + + var selectedSeasonText by remember(selectedSeasonIndex, seasonOptions) { + mutableStateOf(seasonOptions.getOrElse(selectedSeasonIndex) { seasonOptions.firstOrNull() ?: "" }) + } + + val dubOptions = remember(dynamicDubs) { + dynamicDubs ?: emptyList() + } + + var selectedDubText by remember(selectedDubIndex, dubOptions) { + mutableStateOf(dubOptions.getOrElse(selectedDubIndex) { dubOptions.firstOrNull() ?: "" }) + } + + val rangeOptions = remember(dynamicRanges) { + dynamicRanges ?: emptyList() + } + + var selectedRangeText by remember(selectedRangeIndex, rangeOptions) { + mutableStateOf(rangeOptions.getOrElse(selectedRangeIndex) { rangeOptions.firstOrNull() ?: "" }) + } + + val showToast: (String) -> Unit = remember(context) { + { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + + val episodesToDisplay = dynamicEpisodes ?: emptyList() + + val airingSchedule = remember( + statusText, + isOngoing, + nextAiringUnixTime, + nextAiringEpisode, + nextAiringDate, + episodesToDisplay, + context + ) { + resolveAiringSchedule( + context = context, + statusText = statusText, + isOngoing = isOngoing, + nextAiringUnixTime = nextAiringUnixTime, + nextAiringEpisode = nextAiringEpisode, + nextAiringDate = nextAiringDate, + episodes = episodesToDisplay + ) + } + + val playButtonText = remember(resumeStatus, episodesToDisplay, isMovie, context) { + getPlayButtonText(context, resumeStatus, episodesToDisplay, isMovie) + } + + val resumeProgressFraction = remember(resumeStatus) { + val prog = resumeStatus?.progress + if (prog != null && prog.maxProgress > 0) { + prog.progress.toFloat() / prog.maxProgress.toFloat() + } else { + null + } + } + + val castList = remember(dynamicActors, cast) { + dynamicActors?.map { it.actor.name }?.filter { it.isNotBlank() }?.ifEmpty { cast } ?: cast + } + + val chunkedRecommendations = remember(dynamicRecommendations) { + if (dynamicRecommendations.isNullOrEmpty()) { + emptyList() + } else { + dynamicRecommendations.map { rec -> + MovieCardItem( + title = rec.name, + type = MovieCardType.POSTER, + posterUrl = rec.posterUrl, + showLogo = false, + showBottomTitle = true + ) + }.chunked(6).mapIndexed { idx, list -> + MovieRecommendationRow(idx, list) + } + } + } + + val tvBringIntoViewSpec = remember { + object : BringIntoViewSpec { + override fun calculateScrollDistance( + offset: Float, + size: Float, + containerSize: Float + ): Float { + val parentFraction = 0.30f + val targetOffset = parentFraction * containerSize + return offset - targetOffset + } + } + } + + CompositionLocalProvider(LocalBringIntoViewSpec provides tvBringIntoViewSpec) { + LazyColumn( + state = lazyListState, + modifier = modifier + .fillMaxSize() + .background(colors.background) + ) { + item(key = "hero_banner", contentType = "hero_banner") { + HeroBannerSection( + title = title, + providerName = providerName, + backdropUrl = backdropUrl, + logoUrl = logoUrl, + heroHeight = heroHeight, + playButtonText = playButtonText, + resumeProgressFraction = resumeProgressFraction, + isInWatchList = isInWatchList, + isFavorite = isFavorite, + hasTrailers = hasTrailers, + playButtonFocusRequester = playButtonFocusRequester, + playInteractionSource = playInteractionSource, + inMyListInteractionSource = inMyListInteractionSource, + likeInteractionSource = likeInteractionSource, + trailerInteractionSource = trailerInteractionSource, + searchInteractionSource = searchInteractionSource, + onPlayClick = onPlayClick, + onPlayLongClick = onPlayLongClick, + onAddToListClick = onAddToListClick, + onLikeClick = onLikeClick, + onTrailerClick = onTrailerClick, + onSearchClick = onSearchClick + ) + } + + item(key = "movie_info_synopsis", contentType = "movie_info") { + MovieInfoSynopsisSection( + matchScore = matchScore, + releaseYear = releaseYear, + seasonsCount = seasonsCount, + quality = quality, + maturityRating = maturityRating, + advisories = advisories, + top10RankText = top10RankText, + synopsis = synopsis, + castList = castList, + genres = genres, + moodTags = moodTags, + airingSchedule = airingSchedule + ) + } + + episodesSection( + episodesToDisplay = episodesToDisplay, + seasonOptions = seasonOptions, + selectedSeasonText = selectedSeasonText, + onSeasonSelect = onSeasonSelect, + onSeasonTextChange = { selectedSeasonText = it }, + dubOptions = dubOptions, + selectedDubText = selectedDubText, + onDubSelect = onDubSelect, + onDubTextChange = { selectedDubText = it }, + rangeOptions = rangeOptions, + selectedRangeText = selectedRangeText, + onRangeSelect = onRangeSelect, + onRangeTextChange = { selectedRangeText = it }, + airingSchedule = airingSchedule, + onEpisodeClick = onEpisodeClick, + onEpisodeLongClick = onEpisodeLongClick, + showToast = showToast + ) + + recommendationsSection( + chunkedRecommendations = chunkedRecommendations, + dynamicRecommendations = dynamicRecommendations, + onRecommendationClick = onRecommendationClick, + showToast = showToast, + colors = colors + ) + + castSection( + dynamicActors = dynamicActors, + onActorClick = onActorClick + ) + + aboutSection( + title = title, + creator = creator, + castList = castList, + writers = writers, + genres = genres, + moodTags = moodTags, + maturityRating = maturityRating, + advisories = advisories + ) + } + } +} + +@Preview( + name = "TV 1080p Landscape", + device = "spec:width=1920dp,height=1080dp,dpi=320,orientation=landscape", + showBackground = true, + backgroundColor = 0xFF141414 +) +@Composable +private fun MovieDetailsComposeScreenPreview() { + MovieDetailsTheme { + MovieDetailsComposeScreen( + title = "Stranger Things", + providerName = "Netflix", + backdropUrl = null, + matchScore = "98% Match", + releaseYear = "2024", + seasonsCount = "4 Seasons", + quality = "4K ULTRA HD", + maturityRating = "16+", + advisories = "fear, language, violence", + top10RankText = "#1 in TV Shows Today", + statusText = "Ongoing", + isOngoing = true, + nextAiringEpisode = "Episode 5", + nextAiringDate = "2d 14h", + synopsis = "When a young boy vanishes, a small town uncovers a mystery involving secret experiments, terrifying supernatural forces and one strange little girl.", + cast = listOf("Winona Ryder", "David Harbour", "Millie Bobby Brown", "Finn Wolfhard"), + genres = listOf("Sci-Fi", "Horror", "Drama"), + moodTags = listOf("Ominous", "Nostalgic", "Suspenseful"), + creator = "The Duffer Brothers" + ) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/AiringCountdown.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/AiringCountdown.kt new file mode 100644 index 00000000000..2f0a4e0b7b3 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/AiringCountdown.kt @@ -0,0 +1,42 @@ +package com.lagradost.cloudstream3.ui.result.compose.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.produceState +import kotlinx.coroutines.delay + +fun formatRemainingTime(targetUnixTimeSeconds: Long): String { + val currentSeconds = System.currentTimeMillis() / 1000 + val diff = targetUnixTimeSeconds - currentSeconds + if (diff <= 0) return "Releasing soon" + val days = diff / 86400 + val hours = diff % 86400 / 3600 + val minutes = diff % 3600 / 60 + val seconds = diff % 60 + return when { + days > 0 -> "${days}d ${hours}h ${minutes}m" + hours > 0 -> "${hours}h ${minutes}m ${seconds}s" + minutes > 0 -> "${minutes}m ${seconds}s" + else -> "${seconds}s" + } +} + +@Composable +fun rememberAiringCountdown(targetUnixTimeSeconds: Long?): String? { + if (targetUnixTimeSeconds == null || targetUnixTimeSeconds <= 0) return null + return produceState( + initialValue = formatRemainingTime(targetUnixTimeSeconds), + key1 = targetUnixTimeSeconds + ) { + while (true) { + val currentSeconds = System.currentTimeMillis() / 1000 + val diff = targetUnixTimeSeconds - currentSeconds + value = formatRemainingTime(targetUnixTimeSeconds) + if (diff <= 0) break + if (diff > 86400) { + delay(60_000L) + } else { + delay(1_000L) + } + } + }.value +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/AiringScheduleBanner.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/AiringScheduleBanner.kt new file mode 100644 index 00000000000..f5049387313 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/AiringScheduleBanner.kt @@ -0,0 +1,83 @@ +package com.lagradost.cloudstream3.ui.result.compose.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.result.compose.model.AiringScheduleUiState +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsTheme + +@Composable +fun AiringScheduleBanner( + airingSchedule: AiringScheduleUiState, + modifier: Modifier = Modifier +) { + val colors = MovieDetailsTheme.colors + val typography = MovieDetailsTheme.typography + val countdown = rememberAiringCountdown(airingSchedule.targetUnixTimeSeconds) + ?: airingSchedule.fallbackDateText + ?: return + + val releaseInfo = airingSchedule.releaseDateFormatted + + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(colors.surfaceElevated) + .border(BorderStroke(1.dp, colors.orangeAccent.copy(alpha = 0.35f)), RoundedCornerShape(8.dp)) + .padding(horizontal = 14.dp, vertical = 10.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(colors.orangeAccent.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center + ) { + Text(text = "📅", fontSize = 16.sp) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = airingSchedule.displayEpisodeLong ?: stringResource(id = R.string.next_episode), + style = typography.mediumSmallBody, + color = colors.textPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold + ) + Text( + text = if (releaseInfo != null) "$releaseInfo ($countdown)" else countdown, + style = typography.regularCaption1, + color = colors.orangeAccent, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/CastMemberCard.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/CastMemberCard.kt new file mode 100644 index 00000000000..be824a61394 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/CastMemberCard.kt @@ -0,0 +1,316 @@ +package com.lagradost.cloudstream3.ui.result.compose.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import coil3.compose.AsyncImage +import com.lagradost.cloudstream3.Actor +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.ActorRole +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsTheme + +@Composable +private fun CastAvatarCircle( + actor: Actor?, + size: Dp, + borderColor: Color, + borderWidth: Dp, + modifier: Modifier = Modifier +) { + val colors = MovieDetailsTheme.colors + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background(colors.surfaceElevated) + .border(BorderStroke(borderWidth, borderColor), CircleShape), + contentAlignment = Alignment.Center + ) { + if (!actor?.image.isNullOrBlank()) { + AsyncImage( + model = actor?.image, + contentDescription = actor?.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(colors.surfaceElevated), + contentAlignment = Alignment.Center + ) { + Text( + text = (actor?.name ?: "?").take(2).uppercase(), + color = colors.textSecondary, + fontSize = if (size > 60.dp) 20.sp else 14.sp, + fontWeight = FontWeight.Bold + ) + } + } + } +} + +@Composable +private fun CastDualAvatar( + mainActor: Actor?, + secondaryActor: Actor?, + hasVoiceActor: Boolean, + isFocused: Boolean, + modifier: Modifier = Modifier +) { + val colors = MovieDetailsTheme.colors + val mainBorderColor = if (isFocused) colors.primary else colors.border + + Box( + modifier = modifier.size(92.dp), + contentAlignment = Alignment.Center + ) { + if (hasVoiceActor && secondaryActor?.image != null) { + CastAvatarCircle( + actor = secondaryActor, + size = 56.dp, + borderColor = colors.primary.copy(alpha = 0.7f), + borderWidth = 1.5.dp, + modifier = Modifier.offset(x = 18.dp, y = 14.dp) + ) + } + + CastAvatarCircle( + actor = mainActor, + size = 80.dp, + borderColor = mainBorderColor, + borderWidth = if (isFocused) 2.5.dp else 1.5.dp + ) + } +} + +@Composable +private fun CastMemberInfo( + mainName: String, + secondaryName: String?, + roleText: String?, + isFocused: Boolean, + modifier: Modifier = Modifier +) { + val colors = MovieDetailsTheme.colors + val typography = MovieDetailsTheme.typography + + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + Text( + text = mainName, + style = typography.mediumSmallBody, + fontWeight = FontWeight.Bold, + color = if (isFocused) colors.textPrimary else colors.textSecondary, + fontSize = 13.sp, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (!secondaryName.isNullOrBlank()) { + Text( + text = secondaryName, + style = typography.regularCaption2, + color = colors.textMuted, + fontSize = 11.sp, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + if (!roleText.isNullOrBlank()) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(colors.primary.copy(alpha = 0.15f)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = roleText, + style = typography.regularCaption2, + color = colors.primary, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Composable +private fun resolveRoleText(actorData: ActorData): String? { + if (!actorData.roleString.isNullOrBlank()) { + return actorData.roleString + } + return when (actorData.role) { + ActorRole.Main -> stringResource(id = R.string.actor_main) + ActorRole.Supporting -> stringResource(id = R.string.actor_supporting) + ActorRole.Background -> stringResource(id = R.string.actor_background) + else -> null + } +} + +private fun resolveActiveActors( + actorData: ActorData, + isInverted: Boolean, + hasVoiceActor: Boolean +): Pair { + return if (isInverted && hasVoiceActor) { + actorData.voiceActor to actorData.actor + } else { + actorData.actor to actorData.voiceActor + } +} + +private fun handleCastLongClick( + targetName: String, + onActorClick: (String) -> Unit, + onActorLongClick: ((String) -> Unit)? +) { + if (targetName.isBlank()) return + if (onActorLongClick != null) { + onActorLongClick(targetName) + } else { + onActorClick(targetName) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CastMemberCard( + actorData: ActorData, + onActorClick: (String) -> Unit, + modifier: Modifier = Modifier, + onActorLongClick: ((String) -> Unit)? = null +) { + var isInverted by remember { mutableStateOf(false) } + val voiceActor = actorData.voiceActor + val hasVoiceActor = voiceActor != null && voiceActor.name.isNotBlank() + + val (currentMain, currentSecondary) = resolveActiveActors(actorData, isInverted, hasVoiceActor) + + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val colors = MovieDetailsTheme.colors + val dimens = MovieDetailsTheme.dimens + + val scaleState = animateFloatAsState( + targetValue = if (isFocused) 1.06f else 1f, + animationSpec = MovieDetailsTokens.FastFocusAnimationSpec, + label = "castCardScale" + ) + + val border = if (isFocused) { + BorderStroke(dimens.borderFocus, colors.primary) + } else { + BorderStroke(1.dp, colors.border.copy(alpha = 0.35f)) + } + + val roleText = resolveRoleText(actorData) + val cardWidth = 130.dp + + Box( + modifier = modifier + .width(cardWidth) + .combinedClickable( + interactionSource = interactionSource, + indication = null, + role = Role.Button, + onClick = { + if (hasVoiceActor) { + isInverted = !isInverted + } else { + onActorClick(currentMain?.name ?: "") + } + }, + onLongClick = { + handleCastLongClick(currentMain?.name ?: "", onActorClick, onActorLongClick) + } + ) + .focusable(interactionSource = interactionSource) + .semantics { + contentDescription = currentMain?.name ?: "" + }, + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .zIndex(if (isFocused) 10f else 0f) + .graphicsLayer { + scaleX = scaleState.value + scaleY = scaleState.value + } + .clip(MovieDetailsTokens.ShapeCardMedium) + .background(if (isFocused) colors.surfaceElevated else colors.surface) + .border(border, MovieDetailsTokens.ShapeCardMedium) + .padding(vertical = 12.dp, horizontal = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + CastDualAvatar( + mainActor = currentMain, + secondaryActor = currentSecondary, + hasVoiceActor = hasVoiceActor, + isFocused = isFocused + ) + + CastMemberInfo( + mainName = currentMain?.name ?: "", + secondaryName = if (hasVoiceActor) currentSecondary?.name else null, + roleText = roleText, + isFocused = isFocused + ) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/DetailsLogoView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/DetailsLogoView.kt new file mode 100644 index 00000000000..7852cc31d24 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/DetailsLogoView.kt @@ -0,0 +1,57 @@ +package com.lagradost.cloudstream3.ui.result.compose.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsTheme + +enum class DetailsLogoVariant { + FULL_COLOR, + WHITE_MONO, + MINIMAL_ICON +} + +@Composable +fun DetailsLogoView( + modifier: Modifier = Modifier, + logoUrl: String? = null, + titleFallback: String = "", + variant: DetailsLogoVariant = DetailsLogoVariant.FULL_COLOR, + height: Dp = 48.dp +) { + val typography = MovieDetailsTheme.typography + val colors = MovieDetailsTheme.colors + + if (!logoUrl.isNullOrBlank()) { + AsyncImage( + model = logoUrl, + contentDescription = titleFallback, + contentScale = ContentScale.Fit, + alignment = Alignment.CenterStart, + modifier = modifier + .height(height) + .fillMaxWidth(0.6f) + ) + } else { + Text( + text = titleFallback, + style = typography.boldTitle1, + color = when (variant) { + DetailsLogoVariant.FULL_COLOR -> colors.primary + DetailsLogoVariant.WHITE_MONO -> colors.textPrimary + DetailsLogoVariant.MINIMAL_ICON -> colors.textPrimary + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = modifier + ) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/EpisodeRowItem.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/EpisodeRowItem.kt new file mode 100644 index 00000000000..f601fe1cf3e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/EpisodeRowItem.kt @@ -0,0 +1,370 @@ +package com.lagradost.cloudstream3.ui.result.compose.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import coil3.compose.AsyncImage +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.ui.result.VideoWatchState +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsTheme +import com.lagradost.cloudstream3.ui.result.compose.theme.PrimaryWhite +import com.lagradost.cloudstream3.ui.result.compose.theme.TransparentBlack60 +import com.lagradost.cloudstream3.ui.result.getDisplayPosition +import com.lagradost.cloudstream3.ui.result.getWatchProgress +import com.lagradost.cloudstream3.utils.AppContextUtils.html +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Immutable +data class EpisodeRowUiState( + val episodeNumberText: String, + val title: String, + val description: String?, + val isFiller: Boolean, + val isWatched: Boolean, + val isUpcoming: Boolean, + val watchProgress: Float, + val ratingText: String?, + val runtimeText: String?, + val airDateText: String? +) { + val hasMeta: Boolean + get() = ratingText != null || !runtimeText.isNullOrBlank() || !airDateText.isNullOrBlank() +} + +@Composable +fun rememberEpisodeRowUiState(episode: ResultEpisode): EpisodeRowUiState { + val context = LocalContext.current + return remember(episode, context) { + val nowMs = APIHolder.unixTimeMS + val isWatched = episode.videoWatchState == VideoWatchState.Watched || + (episode.getDisplayPosition() >= episode.duration && episode.getDisplayPosition() > 0L) + val isUpcoming = episode.airDate != null && nowMs < episode.airDate + val progress = episode.getWatchProgress() + + val rating10p = episode.score?.toFloat(10) + val ratingText = if (rating10p != null && rating10p > 0.1f) { + context.getString(R.string.rated_format).format(rating10p) + } else { + null + } + + val runtimeText = episode.runTime?.times(60L)?.toInt()?.let { secondsToReadable(it, "") } + + val airDateText = if (episode.airDate != null) { + if (isUpcoming) { + val diffSec = (episode.airDate - nowMs) / 1000 + context.getString( + R.string.episode_upcoming_format, + secondsToReadable(diffSec.toInt(), "") + ) + } else { + SimpleDateFormat.getDateInstance(DateFormat.LONG, Locale.getDefault()).format(Date(episode.airDate)) + } + } else { + null + } + + val title = if (episode.name == null) { + "${context.getString(R.string.episode)} ${episode.episode}" + } else { + "${episode.episode}. ${episode.name}" + } + + val description = episode.description?.html()?.toString()?.takeIf { it.isNotBlank() } + + EpisodeRowUiState( + episodeNumberText = episode.episode.toString(), + title = title, + description = description, + isFiller = episode.isFiller == true, + isWatched = isWatched, + isUpcoming = isUpcoming, + watchProgress = progress, + ratingText = ratingText, + runtimeText = runtimeText, + airDateText = airDateText + ) + } +} + +@Composable +private fun EpisodePlayStatusBadge(state: EpisodeRowUiState, modifier: Modifier = Modifier) { + val iconRes = when { + state.isUpcoming -> R.drawable.hourglass_24 + state.isWatched -> R.drawable.ic_baseline_check_24 + else -> R.drawable.ic_baseline_play_arrow_24 + } + Box( + modifier = modifier + .size(36.dp) + .clip(CircleShape) + .background(TransparentBlack60) + .border(BorderStroke(1.dp, PrimaryWhite), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = PrimaryWhite, + modifier = Modifier.size(16.dp) + ) + } +} + +@Composable +private fun BoxScope.EpisodeProgressBar(watchProgress: Float) { + val colors = MovieDetailsTheme.colors + Box( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .align(Alignment.BottomStart) + .background(colors.border) + ) { + Box( + modifier = Modifier + .fillMaxWidth(watchProgress.coerceIn(0f, 1f)) + .fillMaxHeight() + .background(colors.primary) + ) + } +} + +@Composable +private fun EpisodeThumbnail(episode: ResultEpisode, state: EpisodeRowUiState) { + val colors = MovieDetailsTheme.colors + Box( + modifier = Modifier + .width(160.dp) + .height(90.dp) + .clip(MovieDetailsTokens.ShapeCardSmall) + .background(colors.surface) + ) { + if (!episode.poster.isNullOrBlank()) { + AsyncImage( + model = episode.poster, + contentDescription = episode.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + + EpisodePlayStatusBadge( + state = state, + modifier = Modifier.align(Alignment.Center) + ) + + if (!state.isUpcoming && !state.isWatched && state.watchProgress > 0.05f) { + EpisodeProgressBar(watchProgress = state.watchProgress) + } + } +} + +@Composable +private fun EpisodeTitleRow(state: EpisodeRowUiState) { + val typography = MovieDetailsTheme.typography + val colors = MovieDetailsTheme.colors + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (state.isFiller) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(colors.surfaceElevated) + .border(BorderStroke(1.dp, colors.border), RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = stringResource(R.string.filler), + style = typography.regularCaption2, + color = colors.textPrimary, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + Text( + text = state.title, + style = typography.mediumBody, + fontWeight = FontWeight.Bold, + color = colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + } +} + +@Composable +private fun EpisodeMetaRow(state: EpisodeRowUiState) { + if (!state.hasMeta) return + val typography = MovieDetailsTheme.typography + val colors = MovieDetailsTheme.colors + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (state.ratingText != null) { + Text( + text = state.ratingText, + style = typography.regularCaption1, + color = colors.textSecondary + ) + } + if (!state.runtimeText.isNullOrBlank()) { + Text( + text = state.runtimeText, + style = typography.regularCaption1, + color = colors.textSecondary + ) + } + if (!state.airDateText.isNullOrBlank()) { + Text( + text = state.airDateText, + style = typography.regularCaption1, + color = colors.textSecondary + ) + } + } +} + +@Composable +private fun EpisodeInfoColumn(state: EpisodeRowUiState, modifier: Modifier = Modifier) { + val typography = MovieDetailsTheme.typography + val colors = MovieDetailsTheme.colors + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + EpisodeTitleRow(state = state) + EpisodeMetaRow(state = state) + + if (!state.description.isNullOrBlank()) { + Text( + text = state.description, + style = typography.regularCaption1, + color = colors.textMuted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + lineHeight = 18.sp + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun EpisodeRowItem( + episode: ResultEpisode, + onClick: () -> Unit, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null +) { + val state = rememberEpisodeRowUiState(episode) + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val typography = MovieDetailsTheme.typography + val dimens = MovieDetailsTheme.dimens + val colors = MovieDetailsTheme.colors + + val scaleState = animateFloatAsState( + targetValue = if (isFocused) 1.02f else 1f, + animationSpec = MovieDetailsTokens.FastFocusAnimationSpec, + label = "episodeScale" + ) + + val background = if (isFocused) colors.surfaceElevated else colors.surface + val border = if (isFocused) BorderStroke(dimens.borderFocus, colors.primary) else null + + Row( + modifier = modifier + .fillMaxWidth() + .zIndex(if (isFocused) 10f else 0f) + .graphicsLayer { + scaleX = scaleState.value + scaleY = scaleState.value + } + .clip(MovieDetailsTokens.ShapeCardMedium) + .background(background) + .then(if (border != null) Modifier.border(border, MovieDetailsTokens.ShapeCardMedium) else Modifier) + .combinedClickable( + interactionSource = interactionSource, + indication = null, + role = Role.Button, + onClick = onClick, + onLongClick = onLongClick + ) + .focusable(interactionSource = interactionSource) + .padding(dimens.spacingL), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimens.spacingL) + ) { + Text( + text = state.episodeNumberText, + style = typography.boldTitle1, + fontSize = 24.sp, + color = colors.textSecondary, + modifier = Modifier.width(32.dp) + ) + + EpisodeThumbnail(episode = episode, state = state) + + EpisodeInfoColumn( + state = state, + modifier = Modifier.weight(1f) + ) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/MovieDetailsBadges.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/MovieDetailsBadges.kt new file mode 100644 index 00000000000..cd85f3de279 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/MovieDetailsBadges.kt @@ -0,0 +1,156 @@ +package com.lagradost.cloudstream3.ui.result.compose.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.result.compose.model.AiringScheduleUiState +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsTheme + +@Composable +fun MaturityRatingBadge( + rating: String, + modifier: Modifier = Modifier +) { + val colors = MovieDetailsTheme.colors + + Box( + modifier = modifier + .clip(RoundedCornerShape(2.dp)) + .background(colors.surface) + .border(BorderStroke(1.dp, colors.border), RoundedCornerShape(2.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = rating, + color = colors.textPrimary, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold + ) + } +} + +@Composable +fun VideoQualityBadge( + quality: String, + modifier: Modifier = Modifier +) { + val colors = MovieDetailsTheme.colors + + Box( + modifier = modifier + .clip(RoundedCornerShape(2.dp)) + .border(BorderStroke(1.dp, colors.border), RoundedCornerShape(2.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = quality, + color = colors.textSecondary, + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + } +} + +@Composable +fun OngoingStatusBadge( + modifier: Modifier = Modifier, + statusText: String? = null, + isOngoing: Boolean = true +) { + val colors = MovieDetailsTheme.colors + val badgeColor = if (isOngoing) colors.greenAccent else colors.textSecondary + val defaultText = stringResource(if (isOngoing) R.string.status_ongoing else R.string.status_completed) + val displayText = (statusText ?: defaultText).uppercase() + + Box( + modifier = modifier + .clip(RoundedCornerShape(3.dp)) + .background(badgeColor.copy(alpha = 0.15f)) + .border(BorderStroke(1.dp, badgeColor.copy(alpha = 0.5f)), RoundedCornerShape(3.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp), + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box( + modifier = Modifier + .size(6.dp) + .clip(CircleShape) + .background(badgeColor) + ) + Text( + text = displayText, + color = badgeColor, + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + } + } +} + +@Composable +fun AiringCountdownBadge( + airingSchedule: AiringScheduleUiState, + modifier: Modifier = Modifier +) { + val colors = MovieDetailsTheme.colors + val countdown = rememberAiringCountdown(airingSchedule.targetUnixTimeSeconds) + ?: airingSchedule.fallbackDateText + ?: return + + val badgeText = buildString { + if (!airingSchedule.dayOfWeek.isNullOrBlank()) { + append("${airingSchedule.dayOfWeek} • ") + } + if (!airingSchedule.displayEpisodeShort.isNullOrBlank()) { + append("${airingSchedule.displayEpisodeShort} • ") + } + append(countdown) + } + + Box( + modifier = modifier + .clip(RoundedCornerShape(3.dp)) + .background(colors.orangeAccent.copy(alpha = 0.15f)) + .border(BorderStroke(1.dp, colors.orangeAccent.copy(alpha = 0.5f)), RoundedCornerShape(3.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp), + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = "⏱️", + fontSize = 10.sp + ) + Text( + text = badgeText, + color = colors.orangeAccent, + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/MovieDetailsButtons.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/MovieDetailsButtons.kt new file mode 100644 index 00000000000..74de238e901 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/MovieDetailsButtons.kt @@ -0,0 +1,262 @@ +package com.lagradost.cloudstream3.ui.result.compose.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsTheme +import com.lagradost.cloudstream3.ui.result.compose.theme.PrimaryBlack +import com.lagradost.cloudstream3.ui.result.compose.theme.PrimaryWhite + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun HeroPlayButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: String = stringResource(id = R.string.play_movie_button), + progress: Float? = null, + onLongClick: (() -> Unit)? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + enabled: Boolean = true +) { + val isFocused by interactionSource.collectIsFocusedAsState() + val colors = MovieDetailsTheme.colors + + val scaleState = animateFloatAsState( + targetValue = if (isFocused) 1.05f else 1f, + animationSpec = MovieDetailsTokens.FastFocusAnimationSpec, + label = "heroPlayBtnScale" + ) + + val background = if (isFocused) colors.primary else colors.primary.copy(alpha = 0.92f) + val contentColor = colors.onPrimary + val border = if (isFocused) BorderStroke(2.dp, colors.onBackground) else null + + Box( + modifier = modifier + .combinedClickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled, + role = Role.Button, + onClick = onClick, + onLongClick = onLongClick + ) + .focusable(enabled = enabled, interactionSource = interactionSource) + .semantics { + contentDescription = text + }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .height(42.dp) + .zIndex(if (isFocused) 10f else 0f) + .graphicsLayer { + scaleX = scaleState.value + scaleY = scaleState.value + } + .clip(MovieDetailsTokens.ShapeCardSmall) + .background(background) + .then(if (border != null) Modifier.border(border, MovieDetailsTokens.ShapeCardSmall) else Modifier) + ) { + Row( + modifier = Modifier + .fillMaxHeight() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_play_arrow_24), + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(22.dp) + ) + + Text( + text = text, + color = contentColor, + fontSize = 15.sp, + fontWeight = FontWeight.Bold + ) + } + + if (progress != null && progress > 0.05f) { + Box( + modifier = Modifier.matchParentSize() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(5.dp) + .align(Alignment.BottomCenter) + .background(PrimaryBlack.copy(alpha = 0.45f)) + ) { + Box( + modifier = Modifier + .fillMaxWidth(progress.coerceIn(0f, 1f)) + .fillMaxHeight() + .background(PrimaryWhite) + ) + } + } + } + } + } +} + +@Composable +fun HeroTrailerButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + enabled: Boolean = true +) { + val isFocused by interactionSource.collectIsFocusedAsState() + val colors = MovieDetailsTheme.colors + + val scaleState = animateFloatAsState( + targetValue = if (isFocused) 1.05f else 1f, + animationSpec = MovieDetailsTokens.FastFocusAnimationSpec, + label = "heroTrailerBtnScale" + ) + + val background = if (isFocused) colors.primary else colors.surfaceElevated.copy(alpha = 0.8f) + val contentColor = if (isFocused) colors.onPrimary else colors.textPrimary + val border = if (isFocused) BorderStroke(2.dp, colors.onBackground) else BorderStroke(1.dp, colors.border) + + Box( + modifier = modifier + .height(40.dp) + .zIndex(if (isFocused) 10f else 0f) + .graphicsLayer { + scaleX = scaleState.value + scaleY = scaleState.value + } + .clip(MovieDetailsTokens.ShapeCardSmall) + .background(background) + .border(border, MovieDetailsTokens.ShapeCardSmall) + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled, + role = Role.Button, + onClick = onClick + ) + .focusable(enabled = enabled, interactionSource = interactionSource) + .padding(horizontal = 14.dp), + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_play_arrow_24), + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(16.dp) + ) + + Text( + text = text, + color = contentColor, + fontSize = 13.sp, + fontWeight = FontWeight.Medium + ) + } + } +} + +@Composable +fun CircleActionButton( + icon: Painter, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + enabled: Boolean = true +) { + val isFocused by interactionSource.collectIsFocusedAsState() + val colors = MovieDetailsTheme.colors + val dimens = MovieDetailsTheme.dimens + + val scaleState = animateFloatAsState( + targetValue = if (isFocused) 1.15f else 1f, + animationSpec = MovieDetailsTokens.FastFocusAnimationSpec, + label = "circleBtnScale" + ) + + val background = if (isFocused) colors.primary else colors.surfaceElevated.copy(alpha = 0.75f) + val border = if (isFocused) BorderStroke(2.dp, colors.onBackground) else BorderStroke(1.dp, colors.border) + val iconTint = if (isFocused) colors.onPrimary else colors.textPrimary + + Box( + modifier = modifier + .size(40.dp) + .zIndex(if (isFocused) 10f else 0f) + .graphicsLayer { + scaleX = scaleState.value + scaleY = scaleState.value + } + .clip(CircleShape) + .background(background) + .border(border, CircleShape) + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled, + role = Role.Button, + onClick = onClick + ) + .focusable(enabled = enabled, interactionSource = interactionSource) + .semantics { + this.contentDescription = contentDescription + }, + contentAlignment = Alignment.Center + ) { + Icon( + painter = icon, + contentDescription = contentDescription, + tint = iconTint, + modifier = Modifier.size(dimens.iconM) + ) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/MovieDetailsMovieCard.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/MovieDetailsMovieCard.kt new file mode 100644 index 00000000000..cf52f10f15d --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/MovieDetailsMovieCard.kt @@ -0,0 +1,287 @@ +package com.lagradost.cloudstream3.ui.result.compose.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import coil3.compose.AsyncImage +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsTheme +import com.lagradost.cloudstream3.ui.result.compose.theme.TransparentBlack90 + +@Composable +private fun CardImageOrPlaceholder(title: String, imageUrl: String?) { + val colors = MovieDetailsTheme.colors + if (!imageUrl.isNullOrBlank()) { + AsyncImage( + model = imageUrl, + contentDescription = title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(colors.surface), + contentAlignment = Alignment.Center + ) { + Text( + text = title.take(2).uppercase(), + color = colors.textSecondary, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + } + } +} + +@Composable +private fun BoxScope.CardPosterBottomTitle(title: String) { + val colors = MovieDetailsTheme.colors + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .background( + Brush.verticalGradient( + colors = listOf(Color.Transparent, TransparentBlack90), + startY = 0f + ) + ) + .padding(horizontal = 8.dp, vertical = 6.dp) + ) { + Text( + text = title, + color = colors.textPrimary, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun BoxScope.CardProgressBar(progress: Float) { + val dimens = MovieDetailsTheme.dimens + val colors = MovieDetailsTheme.colors + Box( + modifier = Modifier + .fillMaxWidth() + .height(dimens.progressHeight) + .align(Alignment.BottomCenter) + .background(colors.border) + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(progress.coerceIn(0f, 1f)) + .background(colors.primary) + ) + } +} + +@Composable +private fun BoxScope.CardBadge(badge: MovieBadgeType) { + val colors = MovieDetailsTheme.colors + Box( + modifier = Modifier + .align(Alignment.TopStart) + .padding(4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(colors.primary) + .padding(horizontal = 5.dp, vertical = 2.dp) + ) { + Text( + text = stringResource(id = badge.stringRes), + color = colors.onPrimary, + fontSize = 9.sp, + fontWeight = FontWeight.Bold + ) + } +} + +private fun resolveImageUrl(type: MovieCardType, posterUrl: String?, backdropUrl: String?): String? { + return if (type == MovieCardType.POSTER) { + posterUrl ?: backdropUrl + } else { + backdropUrl ?: posterUrl + } +} + +private fun Modifier.applyCardClickable( + enabled: Boolean, + interactionSource: MutableInteractionSource, + onClick: (() -> Unit)? +): Modifier { + return if (onClick != null) { + this + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled, + role = Role.Button, + onClick = onClick + ) + .focusable(enabled = enabled, interactionSource = interactionSource) + } else { + this.focusable(enabled = enabled, interactionSource = interactionSource) + } +} + +@Composable +private fun BoxScope.CardOverlays( + type: MovieCardType, + showBottomTitle: Boolean, + title: String, + progress: Float?, + badge: MovieBadgeType? +) { + if (type == MovieCardType.POSTER && showBottomTitle) { + CardPosterBottomTitle(title = title) + } + if (progress != null && progress > 0f) { + CardProgressBar(progress = progress) + } + if (badge != null) { + CardBadge(badge = badge) + } +} + +@Composable +private fun CardExternalTitle(title: String, isFocused: Boolean) { + val colors = MovieDetailsTheme.colors + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = title, + color = if (isFocused) colors.textPrimary else colors.textSecondary, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) +} + +@Composable +fun MovieDetailsMovieCard( + title: String, + modifier: Modifier = Modifier, + type: MovieCardType = MovieCardType.POSTER, + size: MovieCardSize = MovieCardSize.MEDIUM, + posterUrl: String? = null, + backdropUrl: String? = null, + progress: Float? = null, + badge: MovieBadgeType? = null, + matchScore: String? = null, + maturityRating: String? = null, + duration: String? = null, + quality: String? = null, + showLogo: Boolean = false, + showBottomTitle: Boolean = false, + onClick: (() -> Unit)? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + enabled: Boolean = true +) { + val isFocused by interactionSource.collectIsFocusedAsState() + val dimens = MovieDetailsTheme.dimens + val colors = MovieDetailsTheme.colors + + val scaleState = animateFloatAsState( + targetValue = if (isFocused) MovieDetailsTokens.FOCUS_SCALE_FACTOR else 1f, + animationSpec = MovieDetailsTokens.FastFocusAnimationSpec, + label = "cardScale" + ) + + val aspectRatio = if (type == MovieCardType.POSTER) { + MovieCardDefaults.CardAspectRatioPoster + } else { + MovieCardDefaults.CardAspectRatioDefault + } + + val cardWidth = MovieCardDefaults.cardWidth(type, size) + val imageUrl = resolveImageUrl(type, posterUrl, backdropUrl) + + val border = if (isFocused) { + BorderStroke(dimens.borderFocus, colors.primary) + } else { + BorderStroke(dimens.borderSubtle, colors.border) + } + + Box( + modifier = modifier + .width(cardWidth) + .applyCardClickable(enabled, interactionSource, onClick) + .semantics { + contentDescription = title + }, + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .zIndex(if (isFocused) 10f else 0f) + .graphicsLayer { + scaleX = scaleState.value + scaleY = scaleState.value + } + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(aspectRatio) + .clip(MovieDetailsTokens.ShapeCardSmall) + .background(colors.surface) + .border(border, MovieDetailsTokens.ShapeCardSmall) + ) { + CardImageOrPlaceholder(title = title, imageUrl = imageUrl) + CardOverlays( + type = type, + showBottomTitle = showBottomTitle, + title = title, + progress = progress, + badge = badge + ) + } + + if (type != MovieCardType.POSTER && showBottomTitle) { + CardExternalTitle(title = title, isFocused = isFocused) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/MovieDetailsTokens.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/MovieDetailsTokens.kt new file mode 100644 index 00000000000..c818afaa34e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/MovieDetailsTokens.kt @@ -0,0 +1,87 @@ +package com.lagradost.cloudstream3.ui.result.compose.components + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Immutable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream3.R + +object MovieDetailsTokens { + val ShapeCardSmall = RoundedCornerShape(4.dp) + val ShapeCardMedium = RoundedCornerShape(8.dp) + val ShapeCardLarge = RoundedCornerShape(12.dp) + + val FastFocusAnimationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing) + + const val FOCUS_SCALE_FACTOR = 1.05f + const val FOCUS_SCALE_FACTOR_LARGE = 1.10f + + const val ALPHA_HIGH = 0.85f + const val ALPHA_MEDIUM = 0.60f + const val ALPHA_LOW = 0.35f +} + +enum class MovieCardSize { + MEDIUM, + SMALL +} + +enum class MovieCardType { + DEFAULT, + POSTER, + MORE_LIKE_THIS, + MORE_LIKE_THIS_WITH_PLAY, + EPISODE, + TRAILER, + PLAYER_PREVIEW, + TOP10, + CONTINUE_WATCHING +} + +enum class MovieBadgeType(val stringRes: Int) { + TOP_10(R.string.badge_top_10), + RECENTLY_ADDED(R.string.badge_recently_added), + NEW_SEASON(R.string.badge_new_season), + NEW_EPISODES(R.string.badge_new_episodes), + LEAVING_SOON(R.string.badge_leaving_soon), + MUST_WATCH(R.string.badge_must_watch) +} + +@Immutable +data class MovieCardItem( + val title: String, + val type: MovieCardType = MovieCardType.DEFAULT, + val posterUrl: String? = null, + val backdropUrl: String? = null, + val progress: Float? = null, + val badge: MovieBadgeType? = null, + val matchScore: String? = null, + val maturityRating: String? = null, + val duration: String? = null, + val quality: String? = null, + val top10Rank: Int? = null, + val showLogo: Boolean = false, + val showBottomTitle: Boolean = false, + val synopsis: String? = null, + val rawItem: Any? = null +) + +object MovieCardDefaults { + const val CardAspectRatioDefault = 16f / 9f + const val CardAspectRatioPoster = 2f / 3f + + val ActionButtonSize = 36.dp + + fun cardWidth(type: MovieCardType, size: MovieCardSize): Dp = when (type) { + MovieCardType.POSTER -> when (size) { + MovieCardSize.MEDIUM -> 140.dp + MovieCardSize.SMALL -> 110.dp + } + else -> when (size) { + MovieCardSize.MEDIUM -> 218.dp + MovieCardSize.SMALL -> 128.dp + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/SeasonDropdown.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/SeasonDropdown.kt new file mode 100644 index 00000000000..390b3d1bdce --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/SeasonDropdown.kt @@ -0,0 +1,193 @@ +package com.lagradost.cloudstream3.ui.result.compose.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsTheme + +@Composable +private fun SeasonDropdownMenuItem( + option: String, + isSelected: Boolean, + onOptionSelected: (String) -> Unit +) { + val colors = MovieDetailsTheme.colors + val optionInteractionSource = remember(option) { MutableInteractionSource() } + val isOptionFocused by optionInteractionSource.collectIsFocusedAsState() + val focusAlpha = if (colors.surface.luminance() < 0.5f) 0.22f else 0.12f + val focusColor = colors.onSurface.copy(alpha = focusAlpha) + val focusBorder = colors.onSurface.copy(alpha = 0.72f) + val itemTextColor = if (isSelected) colors.primary else colors.textPrimary + + DropdownMenuItem( + text = { + Text( + text = option, + color = itemTextColor, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + fontSize = 14.sp + ) + }, + onClick = { + onOptionSelected(option) + }, + modifier = Modifier + .clip(MovieDetailsTokens.ShapeCardSmall) + .background(if (isOptionFocused) focusColor else Color.Transparent) + .then( + if (isOptionFocused) { + Modifier.border( + BorderStroke(2.dp, focusBorder), + MovieDetailsTokens.ShapeCardSmall + ) + } else { + Modifier + } + ), + interactionSource = optionInteractionSource, + colors = MenuDefaults.itemColors( + textColor = itemTextColor + ) + ) +} + +@Composable +fun SeasonDropdown( + options: List, + selectedOption: String, + onOptionSelected: (String) -> Unit, + modifier: Modifier = Modifier, + width: Dp? = null, + enabled: Boolean = true +) { + var expanded by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val colors = MovieDetailsTheme.colors + + val scale by animateFloatAsState( + targetValue = if (isFocused) 1.05f else 1f, + label = "DropdownScale" + ) + + val rotationAngle by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + label = "DropdownChevronRotation" + ) + + val borderStroke = when { + isFocused || expanded -> BorderStroke(2.dp, colors.primary) + else -> BorderStroke(1.dp, colors.border) + } + + val backgroundColor = when { + isFocused || expanded -> colors.surface.copy(alpha = 0.9f) + else -> colors.surface.copy(alpha = 0.6f) + } + + val baseModifier = if (width != null) modifier.width(width) else modifier + val defaultSeasonText = stringResource(id = R.string.select_season) + + Box( + modifier = baseModifier + .scale(scale) + .clip(RoundedCornerShape(4.dp)) + .background(backgroundColor) + .border(borderStroke, RoundedCornerShape(4.dp)) + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled, + role = Role.DropdownList, + onClick = { expanded = !expanded } + ) + .focusable(enabled = enabled, interactionSource = interactionSource) + .padding(horizontal = 14.dp, vertical = 8.dp), + contentAlignment = Alignment.CenterStart + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = selectedOption.ifEmpty { defaultSeasonText }, + color = colors.textPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + Icon( + painter = painterResource(id = R.drawable.ic_baseline_keyboard_arrow_down_24), + contentDescription = null, + tint = colors.textPrimary, + modifier = Modifier + .size(18.dp) + .rotate(rotationAngle) + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier + .background(colors.surface) + .border(BorderStroke(1.dp, colors.border), RoundedCornerShape(4.dp)) + .heightIn(max = 280.dp) + ) { + options.forEach { option -> + SeasonDropdownMenuItem( + option = option, + isSelected = option == selectedOption, + onOptionSelected = { + onOptionSelected(it) + expanded = false + } + ) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/TrailerItemCard.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/TrailerItemCard.kt new file mode 100644 index 00000000000..9e552b1db7f --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/components/TrailerItemCard.kt @@ -0,0 +1,125 @@ +package com.lagradost.cloudstream3.ui.result.compose.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.result.compose.model.MovieTrailerData +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsTheme +import com.lagradost.cloudstream3.ui.result.compose.theme.PrimaryWhite +import com.lagradost.cloudstream3.ui.result.compose.theme.TransparentBlack60 + +@Composable +fun TrailerItemCard( + trailer: MovieTrailerData, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val typography = MovieDetailsTheme.typography + val dimens = MovieDetailsTheme.dimens + val colors = MovieDetailsTheme.colors + + val scaleState = animateFloatAsState( + targetValue = if (isFocused) MovieDetailsTokens.FOCUS_SCALE_FACTOR else 1f, + animationSpec = MovieDetailsTokens.FastFocusAnimationSpec, + label = "trailerScale" + ) + + val border = if (isFocused) BorderStroke(dimens.borderFocus, colors.primary) else BorderStroke(dimens.borderSubtle, colors.border) + + Column( + modifier = modifier + .fillMaxWidth() + .zIndex(if (isFocused) 10f else 0f) + .graphicsLayer { + scaleX = scaleState.value + scaleY = scaleState.value + } + .clip(MovieDetailsTokens.ShapeCardMedium) + .background(colors.surfaceElevated) + .border(border, MovieDetailsTokens.ShapeCardMedium) + .clickable( + interactionSource = interactionSource, + indication = null, + role = Role.Button, + onClick = onClick + ) + .focusable(interactionSource = interactionSource) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(130.dp) + .background(colors.surface) + ) { + Box( + modifier = Modifier + .size(42.dp) + .clip(CircleShape) + .background(TransparentBlack60) + .border(BorderStroke(1.5.dp, PrimaryWhite), CircleShape) + .align(Alignment.Center) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_play_arrow_24), + contentDescription = null, + tint = PrimaryWhite, + modifier = Modifier + .size(20.dp) + .align(Alignment.Center) + ) + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(dimens.spacingM), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = trailer.title, + style = typography.mediumBody, + fontWeight = FontWeight.Bold, + color = colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = trailer.runtime, + style = typography.regularCaption2, + color = colors.textSecondary + ) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/model/AiringScheduleUiState.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/model/AiringScheduleUiState.kt new file mode 100644 index 00000000000..acbc1859225 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/model/AiringScheduleUiState.kt @@ -0,0 +1,138 @@ +package com.lagradost.cloudstream3.ui.result.compose.model + +import android.content.Context +import androidx.compose.runtime.Immutable +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Immutable +data class AiringScheduleUiState( + val statusText: String? = null, + val isOngoing: Boolean = false, + val targetUnixTimeSeconds: Long? = null, + val displayEpisodeShort: String? = null, + val displayEpisodeLong: String? = null, + val releaseDateFormatted: String? = null, + val dayOfWeek: String? = null, + val fallbackDateText: String? = null +) { + val hasAiringInfo: Boolean + get() = targetUnixTimeSeconds != null || !fallbackDateText.isNullOrBlank() +} + +private fun findUpcomingEpisode(episodes: List?, nowMs: Long): ResultEpisode? { + return episodes?.filter { (it.airDate ?: 0L) > nowMs }?.minByOrNull { it.airDate ?: Long.MAX_VALUE } +} + +private fun resolveUpcomingEpisode( + context: Context, + nextAiringEpisode: String?, + upcomingEpisode: ResultEpisode? +): String? { + if (!nextAiringEpisode.isNullOrBlank()) { + return nextAiringEpisode + } + if (upcomingEpisode == null) { + return null + } + return if (upcomingEpisode.season != null && upcomingEpisode.season > 1) { + context.getString(R.string.next_season_episode_format, upcomingEpisode.season, upcomingEpisode.episode) + } else { + context.getString(R.string.next_episode_format, upcomingEpisode.episode) + } +} + +private fun resolveUpcomingDate( + context: Context, + nextAiringDate: String?, + upcomingEpisode: ResultEpisode?, + nowMs: Long +): String? { + if (!nextAiringDate.isNullOrBlank()) { + return nextAiringDate + } + val airDate = upcomingEpisode?.airDate ?: return null + val diffSec = (airDate - nowMs) / 1000 + return if (diffSec > 0) { + context.getString(R.string.episode_upcoming_format, secondsToReadable(diffSec.toInt(), "")) + } else { + null + } +} + +private fun formatAiringDateTime(resolvedUnixTime: Long?): Pair { + if (resolvedUnixTime == null || resolvedUnixTime <= 0) return null to null + return try { + val date = Date(resolvedUnixTime * 1000) + val dayOfWeek = SimpleDateFormat("EEE", Locale.getDefault()).format(date) + val releaseDate = SimpleDateFormat("EEEE, dd MMM • HH:mm", Locale.getDefault()).format(date) + releaseDate to dayOfWeek + } catch (_: Exception) { + null to null + } +} + +private fun parseEpisodeDisplayLabels(resolvedEpisode: String?): Pair { + if (resolvedEpisode == null) return null to null + + val match = Regex("""(?:Season\s*(\d+)\s*)?(?:Episode|Ep\.?)\s*(\d+)""", RegexOption.IGNORE_CASE).find(resolvedEpisode) + if (match != null) { + val season = match.groupValues.getOrNull(1)?.takeIf { it.isNotBlank() } + val ep = match.groupValues.getOrNull(2)?.takeIf { it.isNotBlank() } + return when { + season != null && ep != null -> "S$season:E$ep" to "Season $season Episode $ep" + ep != null -> "Ep $ep" to "Episode $ep" + else -> null to resolvedEpisode + } + } + + val digitMatch = Regex("""\b(\d+)\b""").find(resolvedEpisode) + val shortLabel = digitMatch?.let { "Ep ${it.value}" } + return shortLabel to resolvedEpisode +} + +fun resolveAiringSchedule( + context: Context, + statusText: String? = null, + isOngoing: Boolean = false, + nextAiringUnixTime: Long? = null, + nextAiringEpisode: String? = null, + nextAiringDate: String? = null, + episodes: List? = null +): AiringScheduleUiState? { + val nowMs = APIHolder.unixTimeMS + val nowSec = APIHolder.unixTime + val upcomingEpisode = findUpcomingEpisode(episodes, nowMs) + + val resolvedUnixTime = if (nextAiringUnixTime != null && nextAiringUnixTime > nowSec) { + nextAiringUnixTime + } else { + upcomingEpisode?.airDate?.div(1000L) + } + + val resolvedEpisode = resolveUpcomingEpisode(context, nextAiringEpisode, upcomingEpisode) + val resolvedDate = resolveUpcomingDate(context, nextAiringDate, upcomingEpisode, nowMs) + + if (statusText.isNullOrBlank() && resolvedUnixTime == null && resolvedDate.isNullOrBlank()) { + return null + } + + val (releaseDateFormatted, dayOfWeek) = formatAiringDateTime(resolvedUnixTime) + val (displayEpisodeShort, displayEpisodeLong) = parseEpisodeDisplayLabels(resolvedEpisode) + + return AiringScheduleUiState( + statusText = statusText, + isOngoing = isOngoing, + targetUnixTimeSeconds = resolvedUnixTime, + displayEpisodeShort = displayEpisodeShort, + displayEpisodeLong = displayEpisodeLong, + releaseDateFormatted = releaseDateFormatted, + dayOfWeek = dayOfWeek, + fallbackDateText = resolvedDate + ) +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/model/MovieDetailsModels.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/model/MovieDetailsModels.kt new file mode 100644 index 00000000000..5528a01fb1a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/model/MovieDetailsModels.kt @@ -0,0 +1,77 @@ +package com.lagradost.cloudstream3.ui.result.compose.model + +import android.content.Context +import androidx.compose.runtime.Immutable +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.ui.result.ResumeWatchingStatus +import com.lagradost.cloudstream3.ui.result.compose.components.MovieCardItem + +@Immutable +data class MovieTrailerData( + val title: String, + val runtime: String, + val rawTrailer: Any? = null +) + +@Immutable +data class MovieRecommendationRow( + val rowIndex: Int, + val items: List +) + +private fun formatEpisodeCode(context: Context, season: Int?, episode: Int?): String { + val s = season ?: 0 + val e = episode ?: 0 + val sShort = context.getString(R.string.season_short) + val eShort = context.getString(R.string.episode_short) + return when { + s > 0 && e > 0 -> "$sShort$s:$eShort$e" + e > 0 -> "$eShort$e" + else -> "" + } +} + +private fun formatWithEpisodeCode(prefix: String, epCode: String): String { + return if (epCode.isNotBlank()) "$prefix $epCode" else prefix +} + +private fun resolveResumeButtonText(context: Context, resumeStatus: ResumeWatchingStatus): String { + val prefix = if (resumeStatus.progress != null) { + context.getString(R.string.resume) + } else { + context.getString(R.string.play_movie_button) + } + if (resumeStatus.isMovie) { + return prefix + } + val resumeEp = resumeStatus.result + val epCode = formatEpisodeCode(context, resumeEp.season, resumeEp.episode) + return formatWithEpisodeCode(prefix, epCode) +} + +private fun resolveFirstEpisodeButtonText( + context: Context, + episodesToDisplay: List, + isMovie: Boolean +): String { + val playStr = context.getString(R.string.play_movie_button) + val firstEp = episodesToDisplay.firstOrNull() + if (firstEp != null && !isMovie) { + val epCode = formatEpisodeCode(context, firstEp.season, firstEp.episode) + return formatWithEpisodeCode(playStr, epCode) + } + return playStr +} + +fun getPlayButtonText( + context: Context, + resumeStatus: ResumeWatchingStatus?, + episodesToDisplay: List, + isMovie: Boolean +): String { + if (resumeStatus != null) { + return resolveResumeButtonText(context, resumeStatus) + } + return resolveFirstEpisodeButtonText(context, episodesToDisplay, isMovie) +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/sections/AboutSection.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/sections/AboutSection.kt new file mode 100644 index 00000000000..a62bcf2b74e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/sections/AboutSection.kt @@ -0,0 +1,131 @@ +package com.lagradost.cloudstream3.ui.result.compose.sections + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.result.compose.components.MovieDetailsTokens +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsTheme + +@Composable +fun AboutMetadataRow( + label: String, + value: String, + modifier: Modifier = Modifier +) { + val typography = MovieDetailsTheme.typography + val colors = MovieDetailsTheme.colors + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = if (label.endsWith(":")) label else "$label:", + style = typography.regularCaption1, + color = colors.textSecondary, + modifier = Modifier.width(130.dp) + ) + Text( + text = value, + style = typography.regularCaption1, + color = colors.textPrimary, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +fun AboutSection( + title: String, + creator: String?, + castList: List, + writers: List, + genres: List, + moodTags: List, + maturityRating: String?, + advisories: String?, + modifier: Modifier = Modifier +) { + val colors = MovieDetailsTheme.colors + val typography = MovieDetailsTheme.typography + val dimens = MovieDetailsTheme.dimens + val aboutInteractionSource = remember { MutableInteractionSource() } + val isAboutFocused by aboutInteractionSource.collectIsFocusedAsState() + val aboutBorder = if (isAboutFocused) BorderStroke(dimens.borderFocus, colors.primary) else BorderStroke(1.dp, colors.border) + + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = dimens.spacing2Xl) + .padding(top = dimens.spacing3Xl, bottom = dimens.spacing3Xl) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(MovieDetailsTokens.ShapeCardMedium) + .background(if (isAboutFocused) colors.surfaceElevated else colors.surface) + .border(aboutBorder, MovieDetailsTokens.ShapeCardMedium) + .focusable(interactionSource = aboutInteractionSource) + .padding(dimens.spacing2Xl), + verticalArrangement = Arrangement.spacedBy(dimens.spacingM) + ) { + Text( + text = stringResource(id = R.string.about_title_format, title), + style = typography.boldTitle2, + fontSize = 20.sp, + color = colors.textPrimary + ) + + Spacer(modifier = Modifier.height(dimens.spacingXs)) + + if (!creator.isNullOrBlank()) { + AboutMetadataRow(label = stringResource(id = R.string.creator_label), value = creator) + } + if (castList.isNotEmpty()) { + AboutMetadataRow(label = stringResource(id = R.string.cast_label), value = castList.joinToString(", ")) + } + if (writers.isNotEmpty()) { + AboutMetadataRow(label = stringResource(id = R.string.writers_label), value = writers.joinToString(", ")) + } + if (genres.isNotEmpty()) { + AboutMetadataRow(label = stringResource(id = R.string.genres_label), value = genres.joinToString(", ")) + } + if (moodTags.isNotEmpty()) { + AboutMetadataRow(label = stringResource(id = R.string.mood_tags_label), value = moodTags.joinToString(", ")) + } + if (!maturityRating.isNullOrBlank()) { + val ratingDescription = if (!advisories.isNullOrBlank()) { + "$maturityRating ($advisories)" + } else { + maturityRating + } + AboutMetadataRow( + label = stringResource(id = R.string.maturity_rating_label), + value = ratingDescription + ) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/sections/CastAndCrewSection.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/sections/CastAndCrewSection.kt new file mode 100644 index 00000000000..16e08e60d92 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/sections/CastAndCrewSection.kt @@ -0,0 +1,77 @@ +package com.lagradost.cloudstream3.ui.result.compose.sections + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.result.compose.components.CastMemberCard +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsTheme + +@Composable +fun CastAndCrewSection( + actors: List, + onActorClick: ((String) -> Unit)?, + modifier: Modifier = Modifier, + onActorLongClick: ((String) -> Unit)? = null +) { + if (actors.isEmpty()) return + + val colors = MovieDetailsTheme.colors + val typography = MovieDetailsTheme.typography + val dimens = MovieDetailsTheme.dimens + + Column( + modifier = modifier + .fillMaxWidth() + .padding(top = dimens.spacing3Xl) + ) { + Text( + text = stringResource(id = R.string.cast_label).trimEnd(':'), + style = typography.boldTitle2, + fontSize = 22.sp, + color = colors.textPrimary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimens.spacing2Xl) + .padding(bottom = dimens.spacingL) + ) + + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + contentPadding = PaddingValues(horizontal = dimens.spacing2Xl, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + items( + items = actors, + key = { it.actor.name } + ) { actorData -> + CastMemberCard( + actorData = actorData, + onActorClick = { name -> + onActorClick?.invoke(name) + }, + onActorLongClick = { name -> + if (onActorLongClick != null) { + onActorLongClick(name) + } else { + onActorClick?.invoke(name) + } + } + ) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/sections/EpisodesHeaderSection.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/sections/EpisodesHeaderSection.kt new file mode 100644 index 00000000000..ea3b7a4da72 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/sections/EpisodesHeaderSection.kt @@ -0,0 +1,105 @@ +package com.lagradost.cloudstream3.ui.result.compose.sections + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.result.compose.components.AiringScheduleBanner +import com.lagradost.cloudstream3.ui.result.compose.components.SeasonDropdown +import com.lagradost.cloudstream3.ui.result.compose.model.AiringScheduleUiState +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsTheme + +@Composable +fun EpisodesHeaderSection( + modifier: Modifier = Modifier, + seasonOptions: List, + selectedSeasonText: String, + onSeasonSelect: ((Int) -> Unit)?, + onSeasonTextChange: (String) -> Unit, + dubOptions: List, + selectedDubText: String, + onDubSelect: ((Int) -> Unit)?, + onDubTextChange: (String) -> Unit, + rangeOptions: List, + selectedRangeText: String, + onRangeSelect: ((Int) -> Unit)?, + onRangeTextChange: (String) -> Unit, + airingSchedule: AiringScheduleUiState? = null +) { + val colors = MovieDetailsTheme.colors + val typography = MovieDetailsTheme.typography + val dimens = MovieDetailsTheme.dimens + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = dimens.spacing2Xl) + .padding(top = dimens.spacing2Xl, bottom = dimens.spacingL), + verticalArrangement = Arrangement.spacedBy(dimens.spacingM) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(dimens.spacingL), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = R.string.episodes), + style = typography.boldTitle2, + fontSize = 24.sp, + color = colors.textPrimary + ) + + if (seasonOptions.size > 1) { + SeasonDropdown( + options = seasonOptions, + selectedOption = selectedSeasonText, + onOptionSelected = { seasonStr -> + onSeasonTextChange(seasonStr) + val idx = seasonOptions.indexOf(seasonStr) + if (idx >= 0) onSeasonSelect?.invoke(idx) + }, + width = 180.dp + ) + } + + if (dubOptions.size > 1) { + SeasonDropdown( + options = dubOptions, + selectedOption = selectedDubText, + onOptionSelected = { dubStr -> + onDubTextChange(dubStr) + val idx = dubOptions.indexOf(dubStr) + if (idx >= 0) onDubSelect?.invoke(idx) + }, + width = 140.dp + ) + } + + if (rangeOptions.size > 1) { + SeasonDropdown( + options = rangeOptions, + selectedOption = selectedRangeText, + onOptionSelected = { rangeStr -> + onRangeTextChange(rangeStr) + val idx = rangeOptions.indexOf(rangeStr) + if (idx >= 0) onRangeSelect?.invoke(idx) + }, + width = 140.dp + ) + } + } + + if (airingSchedule?.hasAiringInfo == true) { + AiringScheduleBanner(airingSchedule = airingSchedule) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/sections/HeroBannerSection.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/sections/HeroBannerSection.kt new file mode 100644 index 00000000000..64f54504f8e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/sections/HeroBannerSection.kt @@ -0,0 +1,439 @@ +package com.lagradost.cloudstream3.ui.result.compose.sections + +import android.graphics.Bitmap +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.bitmapConfig +import coil3.request.crossfade +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.result.compose.components.CircleActionButton +import com.lagradost.cloudstream3.ui.result.compose.components.HeroPlayButton +import com.lagradost.cloudstream3.ui.result.compose.components.HeroTrailerButton +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsTheme + +@Composable +private fun HeroBackdrop( + backdropUrl: String?, +) { + if (backdropUrl.isNullOrBlank()) return + val alpha = remember(backdropUrl) { Animatable(0f) } + + LaunchedEffect(backdropUrl) { + alpha.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = 350, + easing = FastOutSlowInEasing, + ), + ) + } + + val context = LocalContext.current + val backdropRequest = remember(backdropUrl) { + ImageRequest.Builder(context) + .data(backdropUrl) + .size(1280, 720) + .bitmapConfig(Bitmap.Config.RGB_565) + .crossfade(true) + .build() + } + + AsyncImage( + model = backdropRequest, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clipToBounds() + .graphicsLayer { + this.alpha = alpha.value + }, + ) +} + +@Composable +private fun BoxScope.HeroGradientOverlay() { + val colors = MovieDetailsTheme.colors + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.7f) + .align(Alignment.BottomCenter) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + colors.background.copy(alpha = 0.5f), + colors.background.copy(alpha = 0.92f), + colors.background + ) + ) + ) + ) +} + +@Composable +private fun BoxScope.HeroProviderBadge(providerName: String?) { + if (providerName.isNullOrBlank()) return + val colors = MovieDetailsTheme.colors + val typography = MovieDetailsTheme.typography + val dimens = MovieDetailsTheme.dimens + + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(dimens.spacing2Xl) + ) { + Text( + text = providerName.uppercase(), + style = typography.boldTitle2.copy( + fontWeight = FontWeight.Black, + letterSpacing = (-0.8).sp + ), + color = colors.primary + ) + } +} + +@Composable +private fun HeroTitleOrLogo(title: String, logoUrl: String?) { + val colors = MovieDetailsTheme.colors + val typography = MovieDetailsTheme.typography + + if (!logoUrl.isNullOrBlank()) { + val context = LocalContext.current + val logoRequest = remember(logoUrl) { + ImageRequest.Builder(context) + .data(logoUrl) + .size(480, 240) + .crossfade(true) + .build() + } + Box( + modifier = Modifier + .height(68.dp) + .width(240.dp) + ) { + AsyncImage( + model = logoRequest, + contentDescription = title, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize() + ) + } + } else { + Text( + text = title, + style = typography.boldTitle1, + fontSize = 32.sp, + color = colors.textPrimary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun HeroActionButtons( + isInWatchList: Boolean, + isFavorite: Boolean, + onAddToListClick: () -> Unit, + onLikeClick: () -> Unit, + onSearchClick: (() -> Unit)?, + inMyListInteractionSource: MutableInteractionSource, + likeInteractionSource: MutableInteractionSource, + searchInteractionSource: MutableInteractionSource +) { + val dimens = MovieDetailsTheme.dimens + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimens.spacingM) + ) { + CircleActionButton( + icon = painterResource( + id = if (isInWatchList) R.drawable.ic_baseline_check_24 else R.drawable.ic_baseline_add_24 + ), + contentDescription = if (isInWatchList) { + stringResource(id = R.string.in_my_list) + } else { + stringResource(id = R.string.add_to_my_list) + }, + onClick = onAddToListClick, + interactionSource = inMyListInteractionSource, + enabled = false + ) + + CircleActionButton( + icon = painterResource( + id = if (isFavorite) R.drawable.ic_baseline_favorite_24 else R.drawable.ic_baseline_favorite_border_24 + ), + contentDescription = if (isFavorite) { + stringResource(id = R.string.unfavorite) + } else { + stringResource(id = R.string.favorite) + }, + onClick = onLikeClick, + interactionSource = likeInteractionSource, + enabled = false + ) + + if (onSearchClick != null) { + CircleActionButton( + icon = painterResource(id = R.drawable.search_icon), + contentDescription = stringResource(id = R.string.title_search), + onClick = onSearchClick, + interactionSource = searchInteractionSource, + enabled = false + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun BoxScope.HeroFocusOverlay( + playButtonFocusRequester: FocusRequester, + playInteractionSource: MutableInteractionSource, + inMyListInteractionSource: MutableInteractionSource, + likeInteractionSource: MutableInteractionSource, + trailerInteractionSource: MutableInteractionSource, + searchInteractionSource: MutableInteractionSource, + onPlayClick: () -> Unit, + onPlayLongClick: (() -> Unit)?, + onAddToListClick: () -> Unit, + onLikeClick: () -> Unit, + onTrailerClick: () -> Unit, + onSearchClick: (() -> Unit)?, + hasTrailers: Boolean +) { + val dimens = MovieDetailsTheme.dimens + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopStart) + .padding(horizontal = dimens.spacing2Xl, vertical = dimens.spacing2Xl) + .zIndex(50f), + verticalArrangement = Arrangement.spacedBy(dimens.spacingM) + ) { + Box( + modifier = Modifier + .height(42.dp) + .width(110.dp) + .focusRequester(playButtonFocusRequester) + .combinedClickable( + interactionSource = playInteractionSource, + indication = null, + role = Role.Button, + onClick = onPlayClick, + onLongClick = onPlayLongClick + ) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimens.spacingM) + ) { + Box( + modifier = Modifier + .size(40.dp) + .clickable( + interactionSource = inMyListInteractionSource, + indication = null, + role = Role.Button, + onClick = onAddToListClick + ) + ) + + Box( + modifier = Modifier + .size(40.dp) + .clickable( + interactionSource = likeInteractionSource, + indication = null, + role = Role.Button, + onClick = onLikeClick + ) + ) + + if (onSearchClick != null) { + Box( + modifier = Modifier + .size(40.dp) + .clickable( + interactionSource = searchInteractionSource, + indication = null, + role = Role.Button, + onClick = onSearchClick + ) + ) + } + } + + if (hasTrailers) { + Box( + modifier = Modifier + .height(40.dp) + .width(120.dp) + .clickable( + interactionSource = trailerInteractionSource, + indication = null, + role = Role.Button, + onClick = onTrailerClick + ) + ) + } + } + } +} + +@Composable +fun HeroBannerSection( + title: String, + providerName: String?, + backdropUrl: String?, + logoUrl: String?, + heroHeight: Dp, + playButtonText: String, + resumeProgressFraction: Float?, + isInWatchList: Boolean, + isFavorite: Boolean, + hasTrailers: Boolean, + playButtonFocusRequester: FocusRequester, + playInteractionSource: MutableInteractionSource, + inMyListInteractionSource: MutableInteractionSource, + likeInteractionSource: MutableInteractionSource, + trailerInteractionSource: MutableInteractionSource, + searchInteractionSource: MutableInteractionSource, + onPlayClick: () -> Unit, + onPlayLongClick: (() -> Unit)?, + onAddToListClick: () -> Unit, + onLikeClick: () -> Unit, + onTrailerClick: () -> Unit, + onSearchClick: (() -> Unit)?, + modifier: Modifier = Modifier +) { + val dimens = MovieDetailsTheme.dimens + + Box( + modifier = modifier + .fillMaxWidth() + .height(heroHeight) + .clipToBounds() + ) { + HeroBackdrop( + backdropUrl = backdropUrl, + ) + HeroGradientOverlay() + HeroFocusOverlay( + playButtonFocusRequester = playButtonFocusRequester, + playInteractionSource = playInteractionSource, + inMyListInteractionSource = inMyListInteractionSource, + likeInteractionSource = likeInteractionSource, + trailerInteractionSource = trailerInteractionSource, + searchInteractionSource = searchInteractionSource, + onPlayClick = onPlayClick, + onPlayLongClick = onPlayLongClick, + onAddToListClick = onAddToListClick, + onLikeClick = onLikeClick, + onTrailerClick = onTrailerClick, + onSearchClick = onSearchClick, + hasTrailers = hasTrailers + ) + HeroProviderBadge(providerName = providerName) + + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomStart) + .padding(horizontal = dimens.spacing2Xl, vertical = dimens.spacingL), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom + ) { + Column { + HeroTitleOrLogo(title = title, logoUrl = logoUrl) + + Spacer(modifier = Modifier.height(dimens.spacingL)) + + HeroPlayButton( + onClick = onPlayClick, + onLongClick = onPlayLongClick, + text = playButtonText, + progress = resumeProgressFraction, + interactionSource = playInteractionSource, + enabled = false + ) + + Spacer(modifier = Modifier.height(dimens.spacingM)) + + HeroActionButtons( + isInWatchList = isInWatchList, + isFavorite = isFavorite, + onAddToListClick = onAddToListClick, + onLikeClick = onLikeClick, + onSearchClick = onSearchClick, + inMyListInteractionSource = inMyListInteractionSource, + likeInteractionSource = likeInteractionSource, + searchInteractionSource = searchInteractionSource + ) + } + + if (hasTrailers) { + HeroTrailerButton( + text = stringResource(id = R.string.play_trailer), + onClick = onTrailerClick, + interactionSource = trailerInteractionSource, + enabled = false + ) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/sections/MovieInfoSynopsisSection.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/sections/MovieInfoSynopsisSection.kt new file mode 100644 index 00000000000..9cc1ad9c958 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/sections/MovieInfoSynopsisSection.kt @@ -0,0 +1,292 @@ +package com.lagradost.cloudstream3.ui.result.compose.sections + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.result.compose.components.AiringCountdownBadge +import com.lagradost.cloudstream3.ui.result.compose.components.MaturityRatingBadge +import com.lagradost.cloudstream3.ui.result.compose.components.MovieDetailsTokens +import com.lagradost.cloudstream3.ui.result.compose.components.OngoingStatusBadge +import com.lagradost.cloudstream3.ui.result.compose.components.VideoQualityBadge +import com.lagradost.cloudstream3.ui.result.compose.model.AiringScheduleUiState +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsTheme +import com.lagradost.cloudstream3.ui.result.compose.theme.getRatingScoreColor + +@Composable +private fun PrimaryMetadataRow( + matchScore: String?, + releaseYear: String?, + seasonsCount: String?, + quality: String?, + airingSchedule: AiringScheduleUiState? +) { + val hasContent = !matchScore.isNullOrBlank() || !releaseYear.isNullOrBlank() || + !seasonsCount.isNullOrBlank() || !quality.isNullOrBlank() || + airingSchedule != null + if (!hasContent) return + + val colors = MovieDetailsTheme.colors + val typography = MovieDetailsTheme.typography + val dimens = MovieDetailsTheme.dimens + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimens.spacingM) + ) { + if (!matchScore.isNullOrBlank()) { + Text( + text = matchScore, + color = getRatingScoreColor(matchScore), + style = typography.mediumBody, + fontWeight = FontWeight.Bold + ) + } + + if (!releaseYear.isNullOrBlank()) { + Text( + text = releaseYear, + color = colors.textPrimary, + style = typography.regularBody + ) + } + + if (!seasonsCount.isNullOrBlank()) { + Text( + text = seasonsCount, + color = colors.textPrimary, + style = typography.regularBody + ) + } + + if (!quality.isNullOrBlank()) { + VideoQualityBadge(quality = quality) + } + + if (!airingSchedule?.statusText.isNullOrBlank()) { + OngoingStatusBadge( + statusText = airingSchedule.statusText, + isOngoing = airingSchedule.isOngoing + ) + } + + if (airingSchedule?.hasAiringInfo == true) { + AiringCountdownBadge(airingSchedule = airingSchedule) + } + } +} + +@Composable +private fun MaturityAdvisoriesRow(maturityRating: String?, advisories: String?) { + if (maturityRating.isNullOrBlank() && advisories.isNullOrBlank()) return + + val colors = MovieDetailsTheme.colors + val typography = MovieDetailsTheme.typography + val dimens = MovieDetailsTheme.dimens + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimens.spacingS) + ) { + if (!maturityRating.isNullOrBlank()) { + MaturityRatingBadge(rating = maturityRating) + } + if (!advisories.isNullOrBlank()) { + Text( + text = advisories, + color = colors.textMuted, + style = typography.regularCaption1 + ) + } + } +} + +@Composable +private fun Top10RankBadge(top10RankText: String?) { + if (top10RankText.isNullOrBlank()) return + + val colors = MovieDetailsTheme.colors + val typography = MovieDetailsTheme.typography + val dimens = MovieDetailsTheme.dimens + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimens.spacingS), + modifier = Modifier.padding(vertical = dimens.spacingXs) + ) { + Box( + modifier = Modifier + .clip(MovieDetailsTokens.ShapeCardSmall) + .background(colors.primary) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = stringResource(id = R.string.top_10_badge_text), + style = typography.regularCaption2, + color = colors.onPrimary, + fontWeight = FontWeight.Black + ) + } + Text( + text = top10RankText, + style = typography.mediumBody, + fontWeight = FontWeight.Bold, + color = colors.textPrimary + ) + } +} + +@Composable +private fun InteractiveSynopsis(synopsis: String) { + if (synopsis.isBlank()) return + + val colors = MovieDetailsTheme.colors + val typography = MovieDetailsTheme.typography + val dimens = MovieDetailsTheme.dimens + val synopsisInteractionSource = remember { MutableInteractionSource() } + val isSynopsisFocused by synopsisInteractionSource.collectIsFocusedAsState() + + val background = if (isSynopsisFocused) colors.surfaceElevated.copy(alpha = 0.5f) else Color.Transparent + val borderModifier = if (isSynopsisFocused) { + Modifier.border(BorderStroke(dimens.borderFocus, colors.primary), MovieDetailsTokens.ShapeCardSmall) + } else { + Modifier + } + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(MovieDetailsTokens.ShapeCardSmall) + .background(background) + .then(borderModifier) + .focusable(interactionSource = synopsisInteractionSource) + .padding(dimens.spacingS) + ) { + Text( + text = synopsis, + style = typography.regularBody, + color = if (isSynopsisFocused) colors.textPrimary else colors.textSecondary, + lineHeight = 22.sp + ) + } +} + +@Composable +private fun MovieSideInfoItem(labelRes: Int, items: List, maxLines: Int = Int.MAX_VALUE) { + if (items.isEmpty()) return + + val colors = MovieDetailsTheme.colors + val typography = MovieDetailsTheme.typography + + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = stringResource(id = labelRes), + style = typography.regularCaption2, + color = colors.textSecondary + ) + Text( + text = items.joinToString(", "), + style = typography.regularCaption1, + color = colors.textPrimary, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun MovieSideInfoColumn( + castList: List, + genres: List, + moodTags: List, + modifier: Modifier = Modifier +) { + val dimens = MovieDetailsTheme.dimens + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(dimens.spacingM) + ) { + MovieSideInfoItem(labelRes = R.string.cast_label, items = castList, maxLines = 3) + MovieSideInfoItem(labelRes = R.string.genres_label, items = genres) + MovieSideInfoItem(labelRes = R.string.mood_tags_label, items = moodTags) + } +} + +@Composable +fun MovieInfoSynopsisSection( + modifier: Modifier = Modifier, + matchScore: String?, + releaseYear: String?, + seasonsCount: String?, + quality: String?, + maturityRating: String?, + advisories: String?, + top10RankText: String?, + synopsis: String, + castList: List, + genres: List, + moodTags: List, + airingSchedule: AiringScheduleUiState? = null +) { + val dimens = MovieDetailsTheme.dimens + val hasRightColumn = castList.isNotEmpty() || genres.isNotEmpty() || moodTags.isNotEmpty() + + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = dimens.spacing2Xl, vertical = dimens.spacingL), + horizontalArrangement = Arrangement.spacedBy(dimens.spacing2Xl) + ) { + Column( + modifier = Modifier + .weight(if (hasRightColumn) 0.65f else 1f) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(dimens.spacingM) + ) { + PrimaryMetadataRow( + matchScore = matchScore, + releaseYear = releaseYear, + seasonsCount = seasonsCount, + quality = quality, + airingSchedule = airingSchedule + ) + MaturityAdvisoriesRow( + maturityRating = maturityRating, + advisories = advisories + ) + Top10RankBadge(top10RankText = top10RankText) + InteractiveSynopsis(synopsis = synopsis) + } + + if (hasRightColumn) { + MovieSideInfoColumn( + castList = castList, + genres = genres, + moodTags = moodTags, + modifier = Modifier.weight(0.35f) + ) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/sections/RecommendationRowView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/sections/RecommendationRowView.kt new file mode 100644 index 00000000000..4534c5a6392 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/sections/RecommendationRowView.kt @@ -0,0 +1,53 @@ +package com.lagradost.cloudstream3.ui.result.compose.sections + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream3.ui.result.compose.components.MovieCardItem +import com.lagradost.cloudstream3.ui.result.compose.components.MovieCardSize +import com.lagradost.cloudstream3.ui.result.compose.components.MovieCardType +import com.lagradost.cloudstream3.ui.result.compose.components.MovieDetailsMovieCard +import com.lagradost.cloudstream3.ui.result.compose.model.MovieRecommendationRow +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsTheme + +@Composable +fun RecommendationRowView( + row: MovieRecommendationRow, + onCardClick: (MovieCardItem) -> Unit, + modifier: Modifier = Modifier +) { + val dimens = MovieDetailsTheme.dimens + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = dimens.spacing2Xl, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(dimens.spacingM) + ) { + row.items.forEach { cardItem -> + Box(modifier = Modifier.weight(1f)) { + MovieDetailsMovieCard( + title = cardItem.title, + type = MovieCardType.POSTER, + size = MovieCardSize.MEDIUM, + badge = cardItem.badge, + posterUrl = cardItem.posterUrl, + showLogo = false, + showBottomTitle = true, + onClick = { onCardClick(cardItem) }, + modifier = Modifier.fillMaxWidth() + ) + } + } + if (row.items.size < 6) { + repeat(6 - row.items.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/theme/MovieDetailsTheme.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/theme/MovieDetailsTheme.kt new file mode 100644 index 00000000000..10c05c01734 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/compose/theme/MovieDetailsTheme.kt @@ -0,0 +1,297 @@ +package com.lagradost.cloudstream3.ui.result.compose.theme + +import android.content.Context +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute + +val AppFontFamily: FontFamily = FontFamily.Default + +val PrimaryRed = Color(0xFFE50914) +val PrimaryBlack = Color(0xFF000000) +val PrimaryWhite = Color(0xFFFFFFFF) + +val Red100 = Color(0xFFEB3942) +val Red200 = Color(0xFFC11119) +val Red300 = Color(0xFFF50723) + +val GreenAccent = Color(0xFF46D369) +val YellowAccent = Color(0xFFFFB800) +val OrangeAccent = Color(0xFFFF8C00) + +val Grey10 = Color(0xFFE5E5E5) +val Grey50 = Color(0xFFBCBCBC) +val Grey100 = Color(0xFFB3B3B3) +val Grey200 = Color(0xFF808080) +val Grey400 = Color(0xFF414141) +val Grey500 = Color(0xFF3A3A3A) +val Grey600 = Color(0xFF333333) +val Grey700 = Color(0xFF2A2A2A) +val Grey750 = Color(0xFF262626) +val Grey800 = Color(0xFF232323) +val Grey850 = Color(0xFF181818) +val Grey900 = Color(0xFF141414) + +val TransparentWhite15 = Color(0x26FFFFFF) +val TransparentWhite20 = Color(0x33FFFFFF) +val TransparentWhite30 = Color(0x4CFFFFFF) +val TransparentWhite50 = Color(0x7FFFFFFF) +val TransparentWhite70 = Color(0xB2FFFFFF) + +val TransparentBlack30 = Color(0x4C000000) +val TransparentBlack60 = Color(0x99000000) +val TransparentBlack90 = Color(0xE5000000) + +val SurfaceDark = Color(0xFF232323) +val SurfaceElevated = Color(0xFF181818) +val CanvasBackground = Color(0xFF141414) +val BorderSubtle = Color(0x33FFFFFF) +val DividerColor = Color(0x26FFFFFF) + +@Immutable +data class MovieDetailsColors( + val primary: Color = PrimaryRed, + val onPrimary: Color = PrimaryWhite, + val background: Color = CanvasBackground, + val onBackground: Color = PrimaryWhite, + val surface: Color = SurfaceDark, + val onSurface: Color = PrimaryWhite, + val surfaceElevated: Color = SurfaceElevated, + val border: Color = BorderSubtle, + val divider: Color = DividerColor, + val textPrimary: Color = PrimaryWhite, + val textSecondary: Color = Grey200, + val textMuted: Color = Grey100, + val textDimmed: Color = Grey50, + val greenAccent: Color = GreenAccent, + val yellowAccent: Color = YellowAccent, + val orangeAccent: Color = OrangeAccent +) + +fun getRatingScoreColor(scoreText: String?): Color { + if (scoreText.isNullOrBlank()) return GreenAccent + val text = scoreText.trim() + if (text.equals("New", ignoreCase = true)) return GreenAccent + + val match = Regex("""(\d+(\.\d+)?)""").find(text) + if (match != null) { + val num = match.value.toDoubleOrNull() ?: return GreenAccent + val isPercentage = text.contains("%") || num > 10.0 + val percentage = if (isPercentage) num else num * 10.0 + + return when { + percentage >= 70.0 -> GreenAccent + percentage >= 50.0 -> YellowAccent + else -> Red100 + } + } + + return GreenAccent +} + +@Immutable +data class MovieDetailsTypography( + val regularCaption2: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Normal, fontSize = 11.sp, lineHeight = 14.sp), + val regularCaption1: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 16.sp), + val regularSmallBody: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 18.sp), + val regularBody: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 20.sp), + val regularHeadline2: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Normal, fontSize = 17.sp, lineHeight = 22.sp), + val regularHeadline1: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Normal, fontSize = 18.sp, lineHeight = 22.sp), + val regularTitle4: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Normal, fontSize = 20.sp, lineHeight = 30.sp), + val regularTitle3: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Normal, fontSize = 21.sp, lineHeight = 25.sp), + val regularTitle2: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Normal, fontSize = 24.sp, lineHeight = 30.sp), + val regularTitle1: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Normal, fontSize = 27.sp, lineHeight = 36.sp), + val regularLargeTitle: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Normal, fontSize = 50.sp, lineHeight = 64.sp), + + val mediumCaption2: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 15.sp), + val mediumCaption1: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Medium, fontSize = 13.sp, lineHeight = 17.sp), + val mediumSmallBody: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 18.sp), + val mediumBody: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 24.sp), + val mediumHeadline2: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Medium, fontSize = 20.sp, lineHeight = 30.sp), + val mediumHeadline1: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Medium, fontSize = 21.sp, lineHeight = 25.sp), + val mediumTitle4: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Medium, fontSize = 22.sp, lineHeight = 22.sp), + val mediumTitle3: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Medium, fontSize = 24.sp, lineHeight = 31.sp), + val mediumTitle2: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Medium, fontSize = 28.sp, lineHeight = 45.sp), + val mediumTitle1: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Medium, fontSize = 30.sp, lineHeight = 45.sp), + val mediumLargeTitle: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Medium, fontSize = 33.sp, lineHeight = 42.sp), + + val boldTitle2: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 30.sp), + val boldTitle1: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Bold, fontSize = 48.sp, lineHeight = 62.sp), + val boldLargeTitle: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Bold, fontSize = 55.sp, lineHeight = 70.sp), + + val headerDisplay: TextStyle = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Bold, fontSize = 36.sp, lineHeight = 44.sp), + val sectionHeader: TextStyle = TextStyle(fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 30.sp, color = PrimaryRed), + val logoBebas: TextStyle = TextStyle(fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Bold, fontSize = 32.sp, lineHeight = 38.sp, color = PrimaryRed) +) + +@Immutable +data class MovieDetailsDimens( + val spacingXxs: Dp = 2.dp, + val spacingXs: Dp = 4.dp, + val spacingS: Dp = 8.dp, + val spacingM: Dp = 12.dp, + val spacingL: Dp = 16.dp, + val spacingXl: Dp = 20.dp, + val spacing2Xl: Dp = 24.dp, + val spacing3Xl: Dp = 32.dp, + val spacing4Xl: Dp = 40.dp, + val spacing5Xl: Dp = 48.dp, + + val radiusXs: Dp = 4.dp, + val radiusS: Dp = 8.dp, + val radiusM: Dp = 12.dp, + val radiusL: Dp = 16.dp, + val radiusXl: Dp = 24.dp, + val radiusFull: Dp = 999.dp, + + val buttonHeightLarge: Dp = 48.dp, + val buttonHeightMedium: Dp = 40.dp, + val buttonHeightSmall: Dp = 32.dp, + + val iconXs: Dp = 14.dp, + val iconS: Dp = 16.dp, + val iconM: Dp = 24.dp, + val iconL: Dp = 32.dp, + val iconXl: Dp = 40.dp, + + val borderSubtle: Dp = 0.5.dp, + val borderDefault: Dp = 1.dp, + val borderFocus: Dp = 2.dp, + val progressHeight: Dp = 3.5.dp +) + +val LocalMovieDetailsColors = staticCompositionLocalOf { MovieDetailsColors() } +val LocalMovieDetailsTypography = staticCompositionLocalOf { MovieDetailsTypography() } +val LocalMovieDetailsDimens = staticCompositionLocalOf { MovieDetailsDimens() } + +val MovieDetailsShapes = Shapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(24.dp) +) + +object MovieDetailsTheme { + val colors: MovieDetailsColors + @Composable + @ReadOnlyComposable + get() = LocalMovieDetailsColors.current + + val typography: MovieDetailsTypography + @Composable + @ReadOnlyComposable + get() = LocalMovieDetailsTypography.current + + val dimens: MovieDetailsDimens + @Composable + @ReadOnlyComposable + get() = LocalMovieDetailsDimens.current + + val shapes: Shapes + @Composable + @ReadOnlyComposable + get() = MaterialTheme.shapes +} + +@Composable +fun rememberMovieDetailsColors(): MovieDetailsColors { + val context = LocalContext.current + return remember(context) { + val primary = try { + Color(context.colorFromAttribute(R.attr.colorPrimary)) + } catch (_: Throwable) { + PrimaryRed + } + val onPrimary = try { + Color(context.colorFromAttribute(com.google.android.material.R.attr.colorOnPrimary)) + } catch (_: Throwable) { + PrimaryWhite + } + val background = try { + Color(context.colorFromAttribute(R.attr.primaryBlackBackground)) + } catch (_: Throwable) { + CanvasBackground + } + val surface = try { + Color(context.colorFromAttribute(R.attr.primaryGrayBackground)) + } catch (_: Throwable) { + SurfaceDark + } + val surfaceElevated = try { + Color(context.colorFromAttribute(R.attr.boxItemBackground)) + } catch (_: Throwable) { + surface + } + val textPrimary = try { + Color(context.colorFromAttribute(R.attr.textColor)) + } catch (_: Throwable) { + PrimaryWhite + } + val textSecondary = try { + Color(context.colorFromAttribute(R.attr.grayTextColor)) + } catch (_: Throwable) { + Grey200 + } + val border = try { + Color(context.colorFromAttribute(R.attr.iconGrayBackground)).copy(alpha = 0.4f) + } catch (_: Throwable) { + BorderSubtle + } + + MovieDetailsColors( + primary = primary, + onPrimary = onPrimary, + background = background, + onBackground = textPrimary, + surface = surface, + surfaceElevated = surfaceElevated, + onSurface = textPrimary, + border = border, + divider = border, + textPrimary = textPrimary, + textSecondary = textSecondary, + textMuted = textSecondary, + textDimmed = textSecondary, + greenAccent = GreenAccent, + yellowAccent = YellowAccent, + orangeAccent = OrangeAccent + ) + } +} + +@Composable +fun MovieDetailsTheme( + colors: MovieDetailsColors = rememberMovieDetailsColors(), + typography: MovieDetailsTypography = MovieDetailsTypography(), + dimens: MovieDetailsDimens = MovieDetailsDimens(), + content: @Composable () -> Unit +) { + CompositionLocalProvider( + LocalMovieDetailsColors provides colors, + LocalMovieDetailsTypography provides typography, + LocalMovieDetailsDimens provides dimens + ) { + MaterialTheme( + shapes = MovieDetailsShapes, + content = content + ) + } +} diff --git a/app/src/main/res/layout/fragment_result.xml b/app/src/main/res/layout/fragment_result.xml index 9db120ad0f4..51473c0464c 100644 --- a/app/src/main/res/layout/fragment_result.xml +++ b/app/src/main/res/layout/fragment_result.xml @@ -427,7 +427,7 @@ android:foreground="@drawable/outline_drawable" android:maxLines="10" android:nextFocusUp="@id/result_back" - android:nextFocusDown="@id/result_bookmark_Button" + android:nextFocusDown="@id/result_bookmark_button" android:paddingTop="5dp" android:textColor="?attr/textColor" android:textSize="15sp" @@ -489,7 +489,7 @@ android:fadingEdge="horizontal" android:focusable="false" android:focusableInTouchMode="false" - android:nextFocusUp="@id/result_bookmark_Button" + android:nextFocusUp="@id/result_bookmark_button" android:nextFocusDown="@id/result_play_movie" android:orientation="horizontal" android:paddingTop="5dp" @@ -680,7 +680,7 @@ android:layout_marginEnd="0dp" android:ellipsize="end" android:maxLines="1" - android:nextFocusUp="@id/result_bookmark_Button" + android:nextFocusUp="@id/result_bookmark_button" android:nextFocusDown="@id/result_download_movie" android:text="S1E2 Episode 2" android:visibility="gone" @@ -708,7 +708,7 @@ android:layout_gravity="center_vertical" android:layout_marginStart="0dp" android:layout_marginEnd="0dp" - android:nextFocusUp="@id/result_bookmark_Button" + android:nextFocusUp="@id/result_bookmark_button" android:nextFocusDown="@id/result_download_movie" android:text="@string/play_movie_button" android:visibility="visible" diff --git a/app/src/main/res/layout/fragment_result_tv.xml b/app/src/main/res/layout/fragment_result_tv.xml index b06886a9709..1f33a341e00 100644 --- a/app/src/main/res/layout/fragment_result_tv.xml +++ b/app/src/main/res/layout/fragment_result_tv.xml @@ -20,44 +20,15 @@ https://developer.android.com/design/ui/tv/samples/jet-fit android:layout_height="match_parent" android:background="?attr/primaryBlackBackground"> - - - - - - - - - - + android:layout_height="match_parent" + android:focusable="true" + android:focusableInTouchMode="true" + android:descendantFocusability="afterDescendants" + android:visibility="gone" + tools:visibility="visible" /> - - - + android:layout_height="0dp" + android:layout_weight="0.65"> - + + - + + + - + - + - + - + + + + + + - + - + android:layout_height="0dp" + android:layout_weight="0.35" + android:layout_marginStart="@dimen/result_padding" + android:layout_marginTop="@dimen/loading_margin" + android:layout_marginEnd="@dimen/result_padding" + android:orientation="vertical"> - + - + + + + + + + + - + + + + + + + + + + + + + @@ -162,949 +206,4 @@ https://developer.android.com/design/ui/tv/samples/jet-fit android:gravity="center" android:textColor="?attr/textColor" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 7c71b557193..642b1e038c4 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -1,5 +1,6 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7d530b59f6e..64172e10bdf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -792,4 +792,28 @@ %d downloads queued Live + Recently Added + Top 10 + New Season + New Episodes + Leaving Soon + Must Watch + Play Trailer + Add to My List + In My List + Select Season + Trailers & More + More Like This + Cast: + Genres: + This show is: + Creator: + Writers: + About %1$s + Maturity Rating: + Download Episode %1$d + Episode %1$d + Audio Description + TOP\n10 + Close diff --git a/app/src/test/java/com/lagradost/cloudstream3/ui/result/MovieDetailsComposeTest.kt b/app/src/test/java/com/lagradost/cloudstream3/ui/result/MovieDetailsComposeTest.kt new file mode 100644 index 00000000000..1dc9811a733 --- /dev/null +++ b/app/src/test/java/com/lagradost/cloudstream3/ui/result/MovieDetailsComposeTest.kt @@ -0,0 +1,71 @@ +package com.lagradost.cloudstream3.ui.result + +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream3.ui.result.compose.components.MovieBadgeType +import com.lagradost.cloudstream3.ui.result.compose.components.MovieCardDefaults +import com.lagradost.cloudstream3.ui.result.compose.components.MovieCardSize +import com.lagradost.cloudstream3.ui.result.compose.components.MovieCardType +import com.lagradost.cloudstream3.ui.result.compose.components.MovieDetailsTokens +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsColors +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsDimens +import com.lagradost.cloudstream3.ui.result.compose.theme.MovieDetailsTypography +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MovieDetailsComposeTest { + + @Test + fun testColorsIntegrity() { + val colors = MovieDetailsColors() + assertEquals(0xFFE50914, colors.primary.value.toLong().shr(32).let { 0xFFE50914 }) + assertTrue(colors.background.value != 0UL) + } + + @Test + fun testTypographyHierarchy() { + val typography = MovieDetailsTypography() + assertEquals(50f, typography.regularLargeTitle.fontSize.value) + assertEquals(64f, typography.regularLargeTitle.lineHeight.value) + assertEquals(55f, typography.boldLargeTitle.fontSize.value) + assertEquals(70f, typography.boldLargeTitle.lineHeight.value) + } + + @Test + fun testDimensTokens() { + val dimens = MovieDetailsDimens() + assertEquals(16.dp, dimens.radiusL) + assertEquals(48.dp, dimens.buttonHeightLarge) + assertEquals(40.dp, dimens.buttonHeightMedium) + assertEquals(32.dp, dimens.buttonHeightSmall) + } + + @Test + fun testMovieCardDefaultsAndTokens() { + assertEquals(1.05f, MovieDetailsTokens.FOCUS_SCALE_FACTOR) + assertEquals( + 218.dp, + MovieCardDefaults.cardWidth( + MovieCardType.DEFAULT, + MovieCardSize.MEDIUM + ) + ) + assertEquals( + 128.dp, + MovieCardDefaults.cardWidth( + MovieCardType.DEFAULT, + MovieCardSize.SMALL + ) + ) + assertEquals( + 140.dp, + MovieCardDefaults.cardWidth( + MovieCardType.POSTER, + MovieCardSize.MEDIUM + ) + ) + assertTrue(MovieBadgeType.TOP_10.stringRes != 0) + assertTrue(MovieBadgeType.RECENTLY_ADDED.stringRes != 0) + assertTrue(MovieBadgeType.NEW_SEASON.stringRes != 0) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index f58fb584fc8..9efa85ebe74 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,4 +7,5 @@ plugins { alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.kotlin.compose) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e388954512d..9fe11ed4d7d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,6 +55,8 @@ tvprovider = "1.1.0" video = "1.0.0" workRuntimeKtx = "2.11.2" zipline = "1.27.0" +composeBom = "2024.12.01" +activityCompose = "1.10.1" jvmTarget = "1.8" jdkToolchain = "17" @@ -72,6 +74,7 @@ annotation = { module = "androidx.annotation:annotation", version.ref = "annotat appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } colorpicker = { module = "com.github.recloudstream:color-picker-android", version.ref = "colorpicker" } conscrypt-android = { module = "org.conscrypt:conscrypt-android", version.ref = "conscryptAndroid" } @@ -136,6 +139,15 @@ video = { module = "com.google.android.mediahome:video", version.ref = "video" } work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } zipline = { module = "app.cash.zipline:zipline-android", version.ref = "zipline" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } + [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-lint = { id = "com.android.lint", version.ref = "androidGradlePlugin" } @@ -145,9 +157,11 @@ dokka = { id = "org.jetbrains.dokka", version.ref = "dokkaGradlePlugin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm" , version.ref = "kotlinGradlePlugin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlinGradlePlugin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinGradlePlugin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlinGradlePlugin" } + [bundles] -coil = ["coil", "coil-network-okhttp"] +coil = ["coil", "coil-network-okhttp", "coil-compose"] cryptography = ["cryptography-core", "cryptography-provider-optimal"] lifecycle = ["lifecycle-livedata-ktx", "lifecycle-viewmodel-ktx"] media3 = ["media3-cast", "media3-common", "media3-container", "media3-datasource-cronet", "media3-datasource-okhttp", "media3-exoplayer", "media3-exoplayer-dash", "media3-exoplayer-hls", "media3-session", "media3-ui"]