diff --git a/app/src/main/java/org/schabi/newpipe/paging/CommentsSource.kt b/app/src/main/java/org/schabi/newpipe/paging/CommentsSource.kt index aec24a344..d92589732 100644 --- a/app/src/main/java/org/schabi/newpipe/paging/CommentsSource.kt +++ b/app/src/main/java/org/schabi/newpipe/paging/CommentsSource.kt @@ -8,13 +8,19 @@ import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.Page import org.schabi.newpipe.extractor.comments.CommentsInfo import org.schabi.newpipe.extractor.comments.CommentsInfoItem +import org.schabi.newpipe.ui.components.video.comment.CommentInfo import org.schabi.newpipe.util.NO_SERVICE_ID class CommentsSource( serviceId: Int, - private val url: String?, - private val repliesPage: Page? + private val url: String, + private val repliesPage: Page?, + private val commentInfo: CommentInfo? = null, ) : PagingSource() { + constructor(commentInfo: CommentInfo) : this( + commentInfo.serviceId, commentInfo.url, commentInfo.nextPage, commentInfo + ) + init { require(serviceId != NO_SERVICE_ID) { "serviceId is NO_SERVICE_ID" } } @@ -29,17 +35,11 @@ class CommentsSource( val info = CommentsInfo.getMoreItems(service, url, it) LoadResult.Page(info.items, null, info.nextPage) } ?: run { - val info = CommentsInfo.getInfo(service, url) - if (info.isCommentsDisabled) { - LoadResult.Error(CommentsDisabledException()) - } else { - LoadResult.Page(info.relatedItems, null, info.nextPage) - } + val info = commentInfo ?: CommentInfo(CommentsInfo.getInfo(service, url)) + LoadResult.Page(info.comments, null, info.nextPage) } } } override fun getRefreshKey(state: PagingState) = null } - -class CommentsDisabledException : RuntimeException() diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt index e5bdc6b91..d02dc1489 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt @@ -166,7 +166,13 @@ fun Comment(comment: CommentsInfoItem) { .cachedIn(coroutineScope) } - CommentSection(parentComment = comment, commentsFlow = flow) + Surface(color = MaterialTheme.colorScheme.background) { + CommentSection( + commentsFlow = flow, + commentCount = comment.replyCount, + parentComment = comment + ) + } } } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt new file mode 100644 index 000000000..2c62739e2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt @@ -0,0 +1,21 @@ +package org.schabi.newpipe.ui.components.video.comment + +import androidx.compose.runtime.Immutable +import org.schabi.newpipe.extractor.Page +import org.schabi.newpipe.extractor.comments.CommentsInfo +import org.schabi.newpipe.extractor.comments.CommentsInfoItem + +@Immutable +class CommentInfo( + val serviceId: Int, + val url: String, + val comments: List, + val nextPage: Page?, + val commentCount: Int, + val isCommentsDisabled: Boolean +) { + constructor(commentsInfo: CommentsInfo) : this( + commentsInfo.serviceId, commentsInfo.url, commentsInfo.relatedItems, commentsInfo.nextPage, + commentsInfo.commentsCount, commentsInfo.isCommentsDisabled + ) +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt index 5e0fc0f0a..d2e0ea122 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt @@ -7,20 +7,19 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.paging.LoadState -import androidx.paging.LoadStates import androidx.paging.PagingData import androidx.paging.compose.collectAsLazyPagingItems import kotlinx.coroutines.flow.Flow @@ -30,106 +29,157 @@ import org.schabi.newpipe.R import org.schabi.newpipe.extractor.Page import org.schabi.newpipe.extractor.comments.CommentsInfoItem import org.schabi.newpipe.extractor.stream.Description -import org.schabi.newpipe.paging.CommentsDisabledException import org.schabi.newpipe.ui.components.common.LoadingIndicator import org.schabi.newpipe.ui.components.common.NoItemsMessage import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.viewmodels.CommentsViewModel +import org.schabi.newpipe.viewmodels.util.Resource @Composable fun CommentSection(commentsViewModel: CommentsViewModel = viewModel()) { - CommentSection(commentsFlow = commentsViewModel.comments) + Surface(color = MaterialTheme.colorScheme.background) { + val state by commentsViewModel.uiState.collectAsStateWithLifecycle() + CommentSection(state, commentsViewModel.comments) + } +} + +@Composable +private fun CommentSection( + uiState: Resource, + commentsFlow: Flow> +) { + when (uiState) { + is Resource.Loading -> LoadingIndicator(modifier = Modifier.padding(top = 8.dp)) + + is Resource.Success -> { + val commentsInfo = uiState.data + CommentSection( + commentsFlow = commentsFlow, + commentCount = commentsInfo.commentCount, + isCommentsDisabled = commentsInfo.isCommentsDisabled + ) + } + + is Resource.Error -> { + // This is not rendered as VideoDetailFragment handles errors + } + } } @Composable fun CommentSection( + commentsFlow: Flow>, + commentCount: Int, parentComment: CommentsInfoItem? = null, - commentsFlow: Flow> + isCommentsDisabled: Boolean = false, ) { val comments = commentsFlow.collectAsLazyPagingItems() - val itemCount by remember { derivedStateOf { comments.itemCount } } val nestedScrollInterop = rememberNestedScrollInteropConnection() val state = rememberLazyListState() - Surface(color = MaterialTheme.colorScheme.background) { - LazyColumnScrollbar(state = state) { - LazyColumn(modifier = Modifier.nestedScroll(nestedScrollInterop), state = state) { - if (parentComment != null) { + LazyColumnScrollbar(state = state) { + LazyColumn( + modifier = Modifier.nestedScroll(nestedScrollInterop), + state = state + ) { + if (parentComment != null) { + item { + CommentRepliesHeader(comment = parentComment) + HorizontalDivider(thickness = 1.dp) + } + } + + if (comments.itemCount == 0) { + item { + val refresh = comments.loadState.refresh + if (refresh is LoadState.Loading) { + LoadingIndicator(modifier = Modifier.padding(top = 8.dp)) + } else { + val message = if (refresh is LoadState.Error) { + R.string.error_unable_to_load_comments + } else if (isCommentsDisabled) { + R.string.comments_are_disabled + } else { + R.string.no_comments + } + NoItemsMessage(message) + } + } + } else { + // The number of replies is already shown in the main comment section + if (parentComment == null) { item { - CommentRepliesHeader(comment = parentComment) - HorizontalDivider(thickness = 1.dp) + Text( + modifier = Modifier.padding(start = 8.dp), + text = pluralStringResource(R.plurals.comments, commentCount, commentCount), + fontWeight = FontWeight.Bold + ) } } - if (itemCount == 0) { - item { - val refresh = comments.loadState.refresh - if (refresh is LoadState.Loading) { - LoadingIndicator(modifier = Modifier.padding(top = 8.dp)) - } else { - val error = (refresh as? LoadState.Error)?.error - val message = if (error is CommentsDisabledException) { - R.string.comments_are_disabled - } else { - R.string.no_comments - } - NoItemsMessage(message) - } - } - } else { - items(itemCount) { - Comment(comment = comments[it]!!) - } + items(comments.itemCount) { + Comment(comment = comments[it]!!) } } } } } -private class CommentDataProvider : PreviewParameterProvider> { - private val notLoading = LoadState.NotLoading(true) - - override val values = sequenceOf( - // Normal view - PagingData.from( - listOf( - CommentsInfoItem( - commentText = Description( - "Comment 1\n\nThis line should be hidden by default.", - Description.PLAIN_TEXT - ), - uploaderName = "Test", - replies = Page(""), - replyCount = 10 - ) - ) + (2..10).map { - CommentsInfoItem( - commentText = Description("Comment $it", Description.PLAIN_TEXT), - uploaderName = "Test" - ) - } - ), - // Comments disabled - PagingData.from( - listOf(), - LoadStates(LoadState.Error(CommentsDisabledException()), notLoading, notLoading) - ), - // No comments - PagingData.from( - listOf(), - LoadStates(notLoading, notLoading, notLoading) - ) - ) +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CommentSectionLoadingPreview() { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + CommentSection(uiState = Resource.Loading, commentsFlow = flowOf()) + } + } } @Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun CommentSectionPreview( - @PreviewParameter(CommentDataProvider::class) pagingData: PagingData -) { +private fun CommentSectionSuccessPreview() { + val comments = listOf( + CommentsInfoItem( + commentText = Description( + "Comment 1\n\nThis line should be hidden by default.", + Description.PLAIN_TEXT + ), + uploaderName = "Test", + replies = Page(""), + replyCount = 10 + ) + ) + (2..10).map { + CommentsInfoItem( + commentText = Description("Comment $it", Description.PLAIN_TEXT), + uploaderName = "Test" + ) + } + AppTheme { - CommentSection(commentsFlow = flowOf(pagingData)) + Surface(color = MaterialTheme.colorScheme.background) { + CommentSection( + uiState = Resource.Success( + CommentInfo( + serviceId = 1, url = "", comments = comments, nextPage = null, + commentCount = 10, isCommentsDisabled = false + ) + ), + commentsFlow = flowOf(PagingData.from(comments)) + ) + } + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CommentSectionErrorPreview() { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + CommentSection(uiState = Resource.Error(RuntimeException()), commentsFlow = flowOf()) + } } } @@ -153,6 +203,8 @@ private fun CommentRepliesPreview() { val flow = flowOf(PagingData.from(replies)) AppTheme { - CommentSection(parentComment = comment, commentsFlow = flow) + Surface(color = MaterialTheme.colorScheme.background) { + CommentSection(parentComment = comment, commentsFlow = flow, commentCount = 10) + } } } diff --git a/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt index 62babb186..007292498 100644 --- a/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt @@ -6,17 +6,39 @@ import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import org.schabi.newpipe.extractor.comments.CommentsInfo import org.schabi.newpipe.paging.CommentsSource -import org.schabi.newpipe.util.KEY_SERVICE_ID +import org.schabi.newpipe.ui.components.video.comment.CommentInfo import org.schabi.newpipe.util.KEY_URL -import org.schabi.newpipe.util.NO_SERVICE_ID +import org.schabi.newpipe.viewmodels.util.Resource class CommentsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { - private val serviceId = savedStateHandle[KEY_SERVICE_ID] ?: NO_SERVICE_ID - private val url = savedStateHandle.get(KEY_URL) + val uiState = savedStateHandle.getStateFlow(KEY_URL, "") + .map { + try { + Resource.Success(CommentInfo(CommentsInfo.getInfo(it))) + } catch (e: Exception) { + Resource.Error(e) + } + } + .flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Resource.Loading) - val comments = Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) { - CommentsSource(serviceId, url, null) - }.flow + @OptIn(ExperimentalCoroutinesApi::class) + val comments = uiState + .filterIsInstance>() + .flatMapLatest { + Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) { + CommentsSource(it.data) + }.flow + } .cachedIn(viewModelScope) } diff --git a/app/src/main/java/org/schabi/newpipe/viewmodels/util/Resource.kt b/app/src/main/java/org/schabi/newpipe/viewmodels/util/Resource.kt new file mode 100644 index 000000000..38bc81391 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/viewmodels/util/Resource.kt @@ -0,0 +1,7 @@ +package org.schabi.newpipe.viewmodels.util + +sealed class Resource { + data object Loading : Resource() + class Success(val data: T) : Resource() + class Error(val throwable: Throwable) : Resource() +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 938a2497d..0539a0daf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -856,4 +856,8 @@ Show less The settings in the export being imported use a vulnerable format that was deprecated since NewPipe 0.27.0. Make sure the export being imported is from a trusted source, and prefer using only exports obtained from NewPipe 0.27.0 or newer in the future. Support for importing settings in this vulnerable format will soon be removed completely, and then old versions of NewPipe will not be able to import settings of exports from new versions anymore. Next + + %d comment + %d comments +