diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java index 1a11836d48b..a5aa1564470 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java @@ -40,6 +40,11 @@ public void addFragment(final Fragment fragment, final String title) { mFragmentTitleList.add(title); } + public void addFragment(final Fragment fragment, final String title, final int position) { + mFragmentList.add(position, fragment); + mFragmentTitleList.add(position, title); + } + public void clearAllItems() { mFragmentList.clear(); mFragmentTitleList.clear(); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index 2d149ec2344..83f009c8bd6 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -85,6 +85,7 @@ import org.schabi.newpipe.fragments.BaseStateFragment import org.schabi.newpipe.fragments.EmptyFragment import org.schabi.newpipe.fragments.MainFragment import org.schabi.newpipe.fragments.list.comments.CommentsFragment.Companion.getInstance +import org.schabi.newpipe.fragments.list.comments.LiveChatFragment import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment.Companion.getInstance import org.schabi.newpipe.ktx.AnimationType import org.schabi.newpipe.ktx.animate @@ -323,7 +324,10 @@ class VideoDetailFragment : if (tabSettingsChanged) { tabSettingsChanged = false initTabs() - currentInfo?.let { updateTabs(it) } + currentInfo?.let { + updateTabs(it) + addLiveChatTabIfNeeded(it) + } } // Check if it was loading when the fragment was stopped/paused @@ -912,6 +916,31 @@ class VideoDetailFragment : } } + private fun addLiveChatTabIfNeeded(streamInfo: StreamInfo) { + if (!streamInfo.hasLiveChat()) { + return + } + val continuation = streamInfo.liveChatContinuation ?: return + if (continuation.isEmpty()) { + return + } + if (pageAdapter.getItemPositionByTitle(LIVE_CHAT_TAB_TAG) != -1) { + return + } + + // Append live chat tab at the end to avoid FragmentPagerAdapter + // position synchronization issues when inserting in the middle + pageAdapter.addFragment( + LiveChatFragment.getInstance(serviceId, url, continuation), + LIVE_CHAT_TAB_TAG + ) + tabIcons.add(R.drawable.ic_live_tv) + tabContentDescriptions.add(R.string.live_chat_tab_description) + pageAdapter.notifyDataSetUpdate() + updateTabIconsAndContentDescriptions() + updateTabLayoutVisibility() + } + fun updateTabLayoutVisibility() { if (nullableBinding == null) { // If binding is null we do not need to and should not do anything with its object(s) @@ -1418,6 +1447,7 @@ class VideoDetailFragment : setInitialData(info.serviceId, info.originalUrl, info.name, playQueue) updateTabs(info) + addLiveChatTabIfNeeded(info) binding.detailThumbnailPlayButton.animate(true, 200) binding.detailVideoTitleView.text = title @@ -2342,6 +2372,7 @@ class VideoDetailFragment : App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED" private const val COMMENTS_TAB_TAG = "COMMENTS" + private const val LIVE_CHAT_TAB_TAG = "LIVE_CHAT" private const val RELATED_TAB_TAG = "NEXT VIDEO" private const val DESCRIPTION_TAB_TAG = "DESCRIPTION TAB" private const val EMPTY_TAB_TAG = "EMPTY TAB" diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/LiveChatFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/LiveChatFragment.kt new file mode 100644 index 00000000000..d2032f50694 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/LiveChatFragment.kt @@ -0,0 +1,39 @@ +package org.schabi.newpipe.fragments.list.comments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.material3.Surface +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.compose.content +import org.schabi.newpipe.ui.components.video.comment.LiveChatSection +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.KEY_SERVICE_ID +import org.schabi.newpipe.util.KEY_URL +import org.schabi.newpipe.viewmodels.LiveChatViewModel + +class LiveChatFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = content { + AppTheme { + Surface { + LiveChatSection() + } + } + } + + companion object { + @JvmStatic + fun getInstance(serviceId: Int, url: String?, liveChatContinuation: String) = LiveChatFragment().apply { + arguments = bundleOf( + KEY_SERVICE_ID to serviceId, + KEY_URL to url, + LiveChatViewModel.KEY_LIVE_CHAT_CONTINUATION to liveChatContinuation + ) + } + } +} 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..6c81697fa85 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 @@ -3,13 +3,12 @@ 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 comments: List, val nextPage: Page?, val commentCount: Int, val isCommentsDisabled: Boolean 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..e488c765405 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 @@ -30,7 +30,6 @@ import kotlinx.coroutines.flow.flowOf import org.schabi.newpipe.R import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.UserAction -import org.schabi.newpipe.extractor.Page import org.schabi.newpipe.extractor.comments.CommentsInfoItem import org.schabi.newpipe.extractor.stream.Description import org.schabi.newpipe.ui.components.common.ErrorPanel @@ -82,7 +81,6 @@ private fun CommentSection( modifier = Modifier .fillMaxWidth() .heightIn(min = 128.dp) - ) } } else if (count == 0) { @@ -95,7 +93,6 @@ private fun CommentSection( ) } } else { - // do not show anything if the comment count is unknown if (count >= 0) { item { Text( @@ -107,6 +104,7 @@ private fun CommentSection( ) } } + when (val refresh = comments.loadState.refresh) { is LoadState.Loading -> { item { @@ -136,8 +134,19 @@ private fun CommentSection( } else -> { - items(comments.itemCount) { - Comment(comment = comments[it]!!) {} + if (comments.itemCount == 0) { + item { + EmptyStateComposable( + spec = EmptyStateSpec.NoComments, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 128.dp) + ) + } + } else { + items(comments.itemCount) { + Comment(comment = comments[it]!!) {} + } } } } @@ -191,7 +200,7 @@ private fun CommentSectionSuccessPreview() { Description.PLAIN_TEXT ), uploaderName = "Test", - replies = Page(""), + replies = org.schabi.newpipe.extractor.Page(""), replyCount = 10 ) ) + (2..10).map { diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/LiveChatSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/LiveChatSection.kt new file mode 100644 index 00000000000..c8b3875af3a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/LiveChatSection.kt @@ -0,0 +1,129 @@ +package org.schabi.newpipe.ui.components.video.comment + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch +import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar +import org.schabi.newpipe.ui.components.common.LoadingIndicator +import org.schabi.newpipe.viewmodels.LiveChatViewModel + +@Composable +fun LiveChatSection(liveChatViewModel: LiveChatViewModel = viewModel()) { + val liveChatItems by liveChatViewModel.liveChatItems.collectAsStateWithLifecycle() + val state = rememberLazyListState() + val nestedScrollInterop = rememberNestedScrollInteropConnection() + val coroutineScope = rememberCoroutineScope() + + // Track whether user is at the top of the list + val isAtTop by remember { derivedStateOf { state.firstVisibleItemIndex == 0 } } + + // Track how many messages were seen while at the top + val lastSeenCount = remember { mutableStateOf(0) } + if (isAtTop && liveChatItems.isNotEmpty()) { + lastSeenCount.value = liveChatItems.size + } + + val unreadCount = liveChatItems.size - lastSeenCount.value + + Box(modifier = Modifier.fillMaxSize()) { + LazyColumnThemedScrollbar(state = state) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(nestedScrollInterop), + state = state + ) { + item { + Text( + modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 4.dp), + text = "Live Chat", + style = MaterialTheme.typography.titleMedium + ) + } + + if (liveChatItems.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 128.dp), + contentAlignment = Alignment.Center + ) { + LoadingIndicator() + } + } + } else { + items(liveChatItems.size, key = { liveChatItems[it].commentId }) { + Comment(comment = liveChatItems[it]) {} + } + } + } + } + + // Floating button to jump to newest messages + AnimatedVisibility( + visible = !isAtTop && unreadCount > 0, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + enter = fadeIn(), + exit = fadeOut() + ) { + FloatingActionButton( + onClick = { + coroutineScope.launch { + state.scrollToItem(0) + } + } + ) { + BadgedBox( + badge = { + Badge( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) { + Text( + text = unreadCount.toString(), + style = MaterialTheme.typography.labelSmall + ) + } + } + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = "Scroll to new messages" + ) + } + } + } + } +} 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..7c5a7c64325 100644 --- a/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt @@ -5,9 +5,11 @@ 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.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest @@ -24,7 +26,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) } @@ -33,7 +36,7 @@ class CommentsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Resource.Loading) @OptIn(ExperimentalCoroutinesApi::class) - val comments = uiState + val comments: Flow> = uiState .filterIsInstance>() .flatMapLatest { Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) { diff --git a/app/src/main/java/org/schabi/newpipe/viewmodels/LiveChatViewModel.kt b/app/src/main/java/org/schabi/newpipe/viewmodels/LiveChatViewModel.kt new file mode 100644 index 00000000000..f278a70f722 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/viewmodels/LiveChatViewModel.kt @@ -0,0 +1,54 @@ +package org.schabi.newpipe.viewmodels + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +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.util.KEY_SERVICE_ID +import org.schabi.newpipe.util.KEY_URL + +class LiveChatViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + private val serviceId: Int = savedStateHandle[KEY_SERVICE_ID] ?: 0 + private val url: String = savedStateHandle[KEY_URL] ?: "" + private val liveChatContinuation: String = + savedStateHandle.get(KEY_LIVE_CHAT_CONTINUATION) ?: "" + + private val _liveChatItems = MutableStateFlow>(emptyList()) + val liveChatItems: StateFlow> = _liveChatItems + + init { + viewModelScope.launch(Dispatchers.IO) { + try { + val service = NewPipe.getService(serviceId) + val extractor = service.getCommentsExtractor(url) + extractor.setLiveChatContinuation(liveChatContinuation) + val info = CommentsInfo.getInfo(extractor) + _liveChatItems.value = info.relatedItems + var nextPage = info.nextPage + while (isActive) { + delay(3000) + if (nextPage == null) continue + val result = CommentsInfo.getMoreItems(service, url, nextPage) + if (result.items.isNotEmpty()) { + _liveChatItems.value = result.items + _liveChatItems.value + } + nextPage = result.nextPage + } + } catch (e: Exception) { + // Ignore initialization errors + } + } + } + + companion object { + const val KEY_LIVE_CHAT_CONTINUATION = "live_chat_continuation" + } +} 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..dada1b72ac1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -284,6 +284,7 @@ Likes Dislikes Comments + Live Chat Related items Description No results @@ -912,4 +913,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 +