diff --git a/app/src/main/kotlin/com/muedsa/agetv/ui/features/detail/AnimeDetailScreen.kt b/app/src/main/kotlin/com/muedsa/agetv/ui/features/detail/AnimeDetailScreen.kt index 19cf20d..f32a192 100644 --- a/app/src/main/kotlin/com/muedsa/agetv/ui/features/detail/AnimeDetailScreen.kt +++ b/app/src/main/kotlin/com/muedsa/agetv/ui/features/detail/AnimeDetailScreen.kt @@ -3,15 +3,10 @@ package com.muedsa.agetv.ui.features.detail import android.content.Intent import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons @@ -25,6 +20,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -38,7 +34,6 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.tv.foundation.lazy.list.TvLazyColumn -import androidx.tv.material3.AssistChip import androidx.tv.material3.ButtonDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon @@ -72,10 +67,7 @@ import com.muedsa.compose.tv.widget.rememberScreenBackgroundState import com.muedsa.uitl.LogUtil import kotlinx.coroutines.flow.update -@OptIn( - ExperimentalTvMaterial3Api::class, - ExperimentalLayoutApi::class -) +@OptIn(ExperimentalTvMaterial3Api::class) @Composable fun AnimeDetailScreen( viewModel: AnimeDetailViewModel = hiltViewModel(), @@ -90,10 +82,12 @@ fun AnimeDetailScreen( val animeDetailLD by viewModel.animeDetailLDSF.collectAsState() val favoriteModel by viewModel.favoriteModelSF.collectAsState() - val watchedEpisodeTitleSet by viewModel.watchedEpisodeTitleSetSF.collectAsState() + val watchedEpisodeTitleMap by viewModel.watchedEpisodeTitleMapSF.collectAsState() val danSearchAnimeListLD by viewModel.danSearchAnimeListLDSF.collectAsState() val danAnimeInfoLD by viewModel.danAnimeInfoLDSF.collectAsState() + val episodeRelationMap = remember { mutableStateMapOf() } + val backgroundState = rememberScreenBackgroundState( initType = ScreenBackgroundType.SCRIM ) @@ -154,8 +148,7 @@ fun AnimeDetailScreen( TvLazyColumn( modifier = Modifier - .offset(x = ScreenPaddingLeft) - .width(screenWidth - ScreenPaddingLeft), + .padding(start = ScreenPaddingLeft), contentPadding = PaddingValues(bottom = 100.dp) ) { // top space @@ -343,82 +336,59 @@ fun AnimeDetailScreen( Spacer(modifier = Modifier.height(20.dp)) } - // 切换播放源 + // 剧集列表 item { - FlowRow( - modifier = Modifier.fillMaxWidth(0.9f), - verticalArrangement = Arrangement.Center, - ) { - selectedPlaySourceList.forEachIndexed { index, item -> - AssistChip( - modifier = Modifier.padding(end = 8.dp, bottom = 8.dp), - onClick = { - LogUtil.fb("click play: $item") - val url = if (animeDetail.isVip(selectedPlaySource)) { - "${animeDetail.playerJx["vip"]}${item[1]}" - } else { - "${animeDetail.playerJx["zj"]}${item[1]}" - } - LogUtil.fb("click playPage: $url") - viewModel.parsePlayInfo( - url = url, - onSuccess = { - LogUtil.fb("try play => $it") - val intent = - Intent(context, PlaybackActivity::class.java) - intent.putExtra( - PlaybackActivity.AID_KEY, - animeDetail.video.id - ) - intent.putExtra( - PlaybackActivity.EPISODE_TITLE_KEY, - item[0] - ) - intent.putExtra( - PlaybackActivity.MEDIA_URL_KEY, - it.realUrl - ) - if (enabledDanmaku - && danAnimeInfoLD.type == LazyType.SUCCESS - && danAnimeInfoLD.data != null - && !danAnimeInfoLD.data?.episodes.isNullOrEmpty() - && danAnimeInfoLD.data?.episodes!!.size > index - ) { - intent.putExtra( - PlaybackActivity.DAN_EPISODE_ID_KEY, - danAnimeInfoLD.data?.episodes!![index].episodeId - ) - } - context.startActivity(intent) - }, - onError = { - errorMsgBoxState.error(it) - } + EpisodeListWidget( + episodeList = selectedPlaySourceList, + danEpisodeList = danAnimeInfoLD.data?.episodes ?: emptyList(), + episodeProgressMap = watchedEpisodeTitleMap, + episodeRelationMap = episodeRelationMap, + onEpisodeClick = { episode, danEpisode -> + LogUtil.fb("click play: $episode") + val url = if (animeDetail.isVip(selectedPlaySource)) { + "${animeDetail.playerJx["vip"]}${episode[1]}" + } else { + "${animeDetail.playerJx["zj"]}${episode[1]}" + } + LogUtil.fb("click playPage: $url") + viewModel.parsePlayInfo( + url = url, + onSuccess = { + LogUtil.fb("try play => $it") + val intent = + Intent(context, PlaybackActivity::class.java) + intent.putExtra( + PlaybackActivity.AID_KEY, + animeDetail.video.id ) - } - ) { - if (enabledDanmaku - && danAnimeInfoLD.type == LazyType.SUCCESS - && danAnimeInfoLD.data != null - && !danAnimeInfoLD.data?.episodes.isNullOrEmpty() - && danAnimeInfoLD.data?.episodes!!.size > index - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = if (watchedEpisodeTitleSet.contains(item[0])) - "${item[0]}*" else item[0] - ) - Text( - text = danAnimeInfoLD.data!!.episodes[index].episodeTitle, - style = MaterialTheme.typography.labelSmall + intent.putExtra( + PlaybackActivity.EPISODE_TITLE_KEY, + episode[0] + ) + intent.putExtra( + PlaybackActivity.MEDIA_URL_KEY, + it.realUrl + ) + if (enabledDanmaku && danEpisode != null) { + intent.putExtra( + PlaybackActivity.DAN_EPISODE_ID_KEY, + danEpisode.episodeId ) } - } else { - Text(text = item[0]) + context.startActivity(intent) + }, + onError = { + errorMsgBoxState.error(it) } + ) + }, + onChangeEpisodeRelation = { + it.forEach { pair -> + episodeRelationMap[pair.first] = pair.second.episodeId } } - } + ) + Spacer(modifier = Modifier.height(25.dp)) } diff --git a/app/src/main/kotlin/com/muedsa/agetv/ui/features/detail/EpisodeListWidget.kt b/app/src/main/kotlin/com/muedsa/agetv/ui/features/detail/EpisodeListWidget.kt new file mode 100644 index 0000000..790f9b8 --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/agetv/ui/features/detail/EpisodeListWidget.kt @@ -0,0 +1,301 @@ +package com.muedsa.agetv.ui.features.detail + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.focusable +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.tv.foundation.lazy.list.TvLazyRow +import androidx.tv.foundation.lazy.list.itemsIndexed +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.WideButton +import com.muedsa.agetv.model.dandanplay.DanEpisode +import com.muedsa.agetv.room.model.EpisodeProgressModel +import com.muedsa.compose.tv.theme.ImageCardRowCardPadding + +const val EpisodePageSize = 20 +val EpisodeProgressStrokeWidth = 12.dp +val WideButtonCornerRadius = 12.dp + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun EpisodeListWidget( + modifier: Modifier = Modifier, + episodeList: List>, + danEpisodeList: List, + episodeProgressMap: Map, + episodeRelationMap: Map, + onEpisodeClick: (List, DanEpisode?) -> Unit = { _, _ -> }, + onChangeEpisodeRelation: (List>) -> Unit = {} +) { + + val episodeListChunks = episodeList.chunked(EpisodePageSize) + val danEpisodeListChunks = danEpisodeList.chunked(EpisodePageSize) + + var changeDanEpisodeMode by remember { mutableStateOf(false) } + var selectedEpisodeIndex by remember { mutableIntStateOf(0) } + var selectedEpisode by remember { mutableStateOf(episodeList[0]) } + + // 通过改变焦点阻止一直触发长点击 + var changeFocusFlagAfterLongClick by remember { mutableIntStateOf(0) } + val focusRequester = remember { FocusRequester() } + + BackHandler(enabled = changeDanEpisodeMode) { + changeDanEpisodeMode = false + } + + Column(modifier = modifier) { + Box( + modifier = Modifier + .size(1.dp) + .focusable(true) + .focusRequester(focusRequester) + ) + if (!changeDanEpisodeMode) { + episodeListChunks.forEachIndexed { chunkIndex, currentPartEpisodeList -> + val episodePartStartNo = 1 + chunkIndex * EpisodePageSize + val episodePartEndNo = episodePartStartNo - 1 + currentPartEpisodeList.size + // 剧集标题 + Row( + modifier = Modifier.padding( + start = ImageCardRowCardPadding, + bottom = ImageCardRowCardPadding + ), + verticalAlignment = Alignment.Bottom + ) { + Text( + text = "剧集 ${episodePartStartNo}-${episodePartEndNo}", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleLarge, + maxLines = 1 + ) + if (chunkIndex == 0) { + Spacer(modifier = Modifier.width(ImageCardRowCardPadding)) + Text( + text = "长按更改匹配的弹幕剧集", + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + style = MaterialTheme.typography.labelSmall, + maxLines = 1 + ) + } + } + + // 剧集列表 + TvLazyRow { + itemsIndexed( + items = currentPartEpisodeList, + key = { _, item -> item[1] } + ) { episodePartIndex, episode -> + WideButton( + modifier = Modifier + .padding(end = 12.dp) + .drawBehind { + // 进度条 + episodeProgressMap[episode[0]]?.let { model -> + val strokeWidthPx = EpisodeProgressStrokeWidth.toPx() + val wideButtonCornerRadiusPx = WideButtonCornerRadius.toPx() + val width = size.width * model.progress / model.duration + val height = strokeWidthPx / 2 + clipRect( + right = width, + bottom = height + ) { + drawRoundRect( + color = Color.Red, + cornerRadius = CornerRadius( + wideButtonCornerRadiusPx, + wideButtonCornerRadiusPx + ) + ) + } + } + }, + title = { + Text(text = episode[0], overflow = TextOverflow.Ellipsis) + }, + subtitle = { + val danEpisode = getDanEpisode( + episode = episode, + episodeIndex = episodePartIndex + chunkIndex * EpisodePageSize, + danEpisodeList = danEpisodeList, + episodeRelationMap = episodeRelationMap + ) + if (danEpisode != null) { + Text( + text = danEpisode.episodeTitle, + overflow = TextOverflow.Ellipsis + ) + } + }, + onClick = { + onEpisodeClick( + episode, getDanEpisode( + episode = episode, + episodeIndex = episodePartIndex + chunkIndex * EpisodePageSize, + danEpisodeList = danEpisodeList, + episodeRelationMap = episodeRelationMap + ) + ) + }, + onLongClick = { + changeFocusFlagAfterLongClick++ + val index = episodePartIndex + chunkIndex * EpisodePageSize + selectedEpisodeIndex = index + selectedEpisode = episodeList[index] + changeDanEpisodeMode = true + } + ) + } + + item { + Spacer(modifier = Modifier.width(100.dp)) + } + } + } + } else { + Row( + modifier = Modifier.padding( + start = ImageCardRowCardPadding, + bottom = ImageCardRowCardPadding + ), + verticalAlignment = Alignment.Bottom + ) { + Text( + text = selectedEpisode[0], + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleLarge, + maxLines = 1 + ) + Spacer(modifier = Modifier.width(ImageCardRowCardPadding)) + Text( + text = "更改对应的弹弹Play剧集", + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f), + style = MaterialTheme.typography.titleMedium, + maxLines = 1 + ) + } + + danEpisodeListChunks.forEachIndexed { danChunkIndex, danEpisodeList -> + val episodePartStartNo = 1 + danChunkIndex * EpisodePageSize + val episodePartEndNo = episodePartStartNo - 1 + danEpisodeList.size + Row( + modifier = Modifier.padding( + start = ImageCardRowCardPadding, + bottom = ImageCardRowCardPadding + ), + verticalAlignment = Alignment.Bottom + ) { + Text( + text = "剧集 ${episodePartStartNo}-${episodePartEndNo}", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleLarge, + maxLines = 1 + ) + if (danChunkIndex == 0) { + Spacer(modifier = Modifier.width(ImageCardRowCardPadding)) + Text( + text = "长按使接下来的剧集都按当前选择的剧集依次匹配", + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + style = MaterialTheme.typography.labelSmall, + maxLines = 1 + ) + } + } + + TvLazyRow { + itemsIndexed( + items = danEpisodeList, + key = { _, item -> item.episodeId } + ) { danEpisodePartIndex, danEpisode -> + WideButton( + modifier = Modifier.padding(end = 12.dp), + title = { + Text( + text = danEpisode.episodeTitle, + overflow = TextOverflow.Ellipsis + ) + }, + onClick = { + onChangeEpisodeRelation(listOf(selectedEpisode[0] to danEpisode)) + changeDanEpisodeMode = false + }, + onLongClick = { + changeFocusFlagAfterLongClick++ + onChangeEpisodeRelation(buildList { + var danEpisodePos = + danEpisodePartIndex + danChunkIndex * EpisodePageSize + for (episodePos in selectedEpisodeIndex..= danEpisodeList.size) { + break + } + add(episodeList[episodePos][0] to danEpisodeList[danEpisodePos]) + danEpisodePos++ + } + }) + changeDanEpisodeMode = false + } + ) + } + + item { + Spacer(modifier = Modifier.width(100.dp)) + } + } + } + } + } + LaunchedEffect(key1 = changeFocusFlagAfterLongClick) { + if (changeFocusFlagAfterLongClick > 0) { + focusRequester.requestFocus() + } + } +} + +fun getDanEpisode( + episode: List, + episodeIndex: Int, + danEpisodeList: List, + episodeRelationMap: Map +): DanEpisode? { + var danEpisode: DanEpisode? = null + + if (danEpisodeList.isNotEmpty()) { + val episodeId = episodeRelationMap[episode[0]] + if (episodeId != null) { + val danEpisodeOptional = danEpisodeList.stream() + .filter { it.episodeId == episodeId } + .findFirst() + if (danEpisodeOptional.isPresent) { + danEpisode = danEpisodeOptional.get() + } + } else { + if (episodeIndex < danEpisodeList.size) { + danEpisode = danEpisodeList[episodeIndex] + } + } + } + return danEpisode +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/agetv/viewmodel/AnimeDetailViewModel.kt b/app/src/main/kotlin/com/muedsa/agetv/viewmodel/AnimeDetailViewModel.kt index 22c2818..a339d71 100644 --- a/app/src/main/kotlin/com/muedsa/agetv/viewmodel/AnimeDetailViewModel.kt +++ b/app/src/main/kotlin/com/muedsa/agetv/viewmodel/AnimeDetailViewModel.kt @@ -62,17 +62,17 @@ class AnimeDetailViewModel @Inject constructor( ) private val _watchedEpisodeTitleSetRefreshSF = MutableStateFlow(0) - val watchedEpisodeTitleSetSF = + val watchedEpisodeTitleMapSF = animeDetailLDSF.combine(_watchedEpisodeTitleSetRefreshSF) { animeDetailLD, _ -> (if (animeDetailLD.type == LazyType.SUCCESS) { animeDetailLD.data?.video?.id?.let { episodeProgressDao.getListByAid(it) } - } else null)?.map { it.title }?.toSet() ?: emptySet() + } else null)?.associateBy({ it.title }, { it }) ?: emptyMap() }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), - initialValue = emptySet() + initialValue = emptyMap() ) private fun animeDetail(aid: Int) {