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 index 3bd3ec2453a..f8f9711dd19 100644 --- 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 @@ -12,7 +12,8 @@ class CommentInfo( val comments: List, val nextPage: Page?, val commentCount: Int, - val isCommentsDisabled: Boolean + val isCommentsDisabled: Boolean, + val isLiveChat: Boolean ) { constructor(commentsInfo: CommentsInfo) : this( commentsInfo.serviceId, @@ -20,6 +21,7 @@ class CommentInfo( commentsInfo.relatedItems, commentsInfo.nextPage, commentsInfo.commentsCount, - commentsInfo.isCommentsDisabled + commentsInfo.isCommentsDisabled, + commentsInfo.isLiveChat ) } 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 4e49676ef3c..43057268b28 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 @@ -12,6 +12,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -45,18 +46,28 @@ import org.schabi.newpipe.viewmodels.util.Resource @Composable fun CommentSection(commentsViewModel: CommentsViewModel = viewModel()) { val state by commentsViewModel.uiState.collectAsStateWithLifecycle() - CommentSection(state, commentsViewModel.comments) + val liveChatItems by commentsViewModel.liveChatItems.collectAsStateWithLifecycle() + CommentSection(state, commentsViewModel.comments, liveChatItems) } @Composable private fun CommentSection( uiState: Resource, - commentsFlow: Flow> + commentsFlow: Flow>, + liveChatItems: List ) { val comments = commentsFlow.collectAsLazyPagingItems() val nestedScrollInterop = rememberNestedScrollInteropConnection() val state = rememberLazyListState() + // Auto-scroll to top when new live chat messages arrive + val isLiveChat = uiState is Resource.Success && uiState.data.isLiveChat + LaunchedEffect(liveChatItems.size) { + if (isLiveChat && liveChatItems.isNotEmpty()) { + state.scrollToItem(0) + } + } + LazyColumnThemedScrollbar(state = state) { LazyColumn( modifier = Modifier @@ -75,7 +86,7 @@ private fun CommentSection( val commentInfo = uiState.data val count = commentInfo.commentCount - if (commentInfo.isCommentsDisabled) { + if (commentInfo.isCommentsDisabled && !commentInfo.isLiveChat) { item { EmptyStateComposable( spec = EmptyStateSpec.DisabledComments, @@ -85,7 +96,7 @@ private fun CommentSection( ) } - } else if (count == 0) { + } else if (count == 0 && !commentInfo.isLiveChat) { item { EmptyStateComposable( spec = EmptyStateSpec.NoComments, @@ -95,8 +106,8 @@ private fun CommentSection( ) } } else { - // do not show anything if the comment count is unknown - if (count >= 0) { + // Show title for regular comments, but not for live chat + if (count >= 0 && !commentInfo.isLiveChat) { item { Text( modifier = Modifier @@ -107,37 +118,68 @@ private fun CommentSection( ) } } - when (val refresh = comments.loadState.refresh) { - is LoadState.Loading -> { + + if (commentInfo.isLiveChat) { + // Live chat: render items directly without Paging 3 + if (liveChatItems.isEmpty()) { item { - LoadingIndicator(modifier = Modifier.padding(top = 8.dp)) + EmptyStateComposable( + spec = EmptyStateSpec.NoComments, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 128.dp) + ) + } + } else { + items(liveChatItems.size, key = { liveChatItems[it].commentId }) { + Comment(comment = liveChatItems[it]) {} } } + } else { + // Normal comments via Paging 3 + when (val refresh = comments.loadState.refresh) { + is LoadState.Loading -> { + item { + LoadingIndicator(modifier = Modifier.padding(top = 8.dp)) + } + } - is LoadState.Error -> { - val errorInfo = ErrorInfo( - throwable = refresh.error, - userAction = UserAction.REQUESTED_COMMENTS, - request = "comments" - ) + is LoadState.Error -> { + val errorInfo = ErrorInfo( + throwable = refresh.error, + userAction = UserAction.REQUESTED_COMMENTS, + request = "comments" + ) - item { - Box( - modifier = Modifier - .fillMaxWidth() - ) { - ErrorPanel( - errorInfo = errorInfo, - onRetry = { comments.retry() }, - modifier = Modifier.align(Alignment.Center) - ) + item { + Box( + modifier = Modifier + .fillMaxWidth() + ) { + ErrorPanel( + errorInfo = errorInfo, + onRetry = { comments.retry() }, + modifier = Modifier.align(Alignment.Center) + ) + } } } - } - else -> { - items(comments.itemCount) { - Comment(comment = comments[it]!!) {} + else -> { + if (comments.itemCount == 0) { + item { + EmptyStateComposable( + spec = EmptyStateSpec.NoComments, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 128.dp) + ) + } + } else { + items(comments.itemCount) { + Comment(comment = comments[it]!!) {} + } + } } } } @@ -175,7 +217,7 @@ private fun CommentSection( private fun CommentSectionLoadingPreview() { AppTheme { Surface { - CommentSection(uiState = Resource.Loading, commentsFlow = flowOf()) + CommentSection(uiState = Resource.Loading, commentsFlow = flowOf(), liveChatItems = emptyList()) } } } @@ -211,10 +253,12 @@ private fun CommentSectionSuccessPreview() { comments = comments, nextPage = null, commentCount = 10, - isCommentsDisabled = false + isCommentsDisabled = false, + isLiveChat = false ) ), - commentsFlow = flowOf(PagingData.from(comments)) + commentsFlow = flowOf(PagingData.from(comments)), + liveChatItems = emptyList() ) } } @@ -226,7 +270,7 @@ private fun CommentSectionSuccessPreview() { private fun CommentSectionErrorPreview() { AppTheme { Surface { - CommentSection(uiState = Resource.Error(RuntimeException()), commentsFlow = flowOf()) + CommentSection(uiState = Resource.Error(RuntimeException()), commentsFlow = flowOf(), liveChatItems = emptyList()) } } } diff --git a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java index b9c91f8a5b0..a268c7d6465 100644 --- a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java +++ b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java @@ -56,7 +56,7 @@ public enum Type { CHANNEL_TAB, COMMENTS, PLAYLIST, - KIOSK, + KIOSK } public static InfoCache getInstance() { 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 00729249855..5965dab77be 100644 --- a/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt @@ -5,16 +5,25 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig +import androidx.paging.PagingData import androidx.paging.cachedIn import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow 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 kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.comments.CommentsInfo +import org.schabi.newpipe.extractor.comments.CommentsInfoItem import org.schabi.newpipe.paging.CommentsSource import org.schabi.newpipe.ui.components.video.comment.CommentInfo import org.schabi.newpipe.util.KEY_URL @@ -24,7 +33,8 @@ class CommentsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { val uiState = savedStateHandle.getStateFlow(KEY_URL, "") .map { try { - Resource.Success(CommentInfo(CommentsInfo.getInfo(it))) + val info = CommentsInfo.getInfo(it) + Resource.Success(CommentInfo(info)) } catch (e: Exception) { Resource.Error(e) } @@ -32,13 +42,51 @@ class CommentsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { .flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Resource.Loading) + // Separate flow for live chat items (not using Paging 3) + private val _liveChatItems = MutableStateFlow>(emptyList()) + val liveChatItems: StateFlow> = _liveChatItems + @OptIn(ExperimentalCoroutinesApi::class) - val comments = uiState + val comments: Flow> = uiState .filterIsInstance>() .flatMapLatest { - Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) { - CommentsSource(it.data) - }.flow + val info = it.data + if (info.isLiveChat) { + _liveChatItems.value = info.comments + startLiveChatPolling(info) + // Return empty PagingData for live chat (items come from liveChatItems flow) + kotlinx.coroutines.flow.flowOf(androidx.paging.PagingData.empty()) + } else { + Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) { + CommentsSource(info) + }.flow + } } .cachedIn(viewModelScope) + + private fun startLiveChatPolling(info: CommentInfo) { + var nextPage = info.nextPage + + viewModelScope.launch(Dispatchers.IO) { + while (isActive) { + delay(3000) + if (nextPage == null) { + continue + } + try { + val result = CommentsInfo.getMoreItems( + NewPipe.getService(info.serviceId), + info.url, + nextPage + ) + if (result.items.isNotEmpty()) { + _liveChatItems.value = result.items + _liveChatItems.value + } + nextPage = result.nextPage + } catch (e: Exception) { + // Silently ignore polling errors + } + } + } + } } diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml index 5df029ab317..2bf8f04bda2 100644 --- a/app/src/main/res/values/settings_keys.xml +++ b/app/src/main/res/values/settings_keys.xml @@ -1513,4 +1513,5 @@ @string/image_quality_medium_key @string/image_quality_high_key + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 207f1363f5f..5c64232cf19 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -912,4 +912,5 @@ In August 2025, Google announced that as of September 2026, installing apps will require developer verification for all Android apps on certified devices, including those installed outside of the Play Store. Since the developers of NewPipe do not agree to this requirement, NewPipe will no longer work on certified Android devices after that time. Details Solution +