1
0
mirror of https://github.com/TeamNewPipe/NewPipe synced 2025-01-09 08:50:34 +00:00

Implement card and list layouts, check for preferred layout from settings

This commit is contained in:
Isira Seneviratne 2024-07-02 08:57:38 +05:30
parent bf1c9ba7b5
commit 72bbe0ea1c
11 changed files with 354 additions and 104 deletions

View File

@ -288,6 +288,7 @@ dependencies {
// Jetpack Compose
implementation(platform('androidx.compose:compose-bom:2024.06.00'))
implementation 'androidx.compose.material3:material3:1.3.0-beta05'
implementation 'androidx.compose.material3.adaptive:adaptive:1.0.0-beta04'
implementation 'androidx.activity:activity-compose'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.ui:ui-text:1.7.0-beta06' // Needed for parsing HTML to AnnotatedString

View File

@ -1,28 +1,39 @@
package org.schabi.newpipe.compose.playlist
import android.content.res.Configuration
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.paging.compose.collectAsLazyPagingItems
import my.nanihadesuka.compose.LazyColumnScrollbar
import my.nanihadesuka.compose.LazyVerticalGridScrollbar
import org.schabi.newpipe.DownloaderImpl
import org.schabi.newpipe.compose.stream.StreamCardItem
import org.schabi.newpipe.compose.stream.StreamGridItem
import org.schabi.newpipe.compose.stream.StreamListItem
import org.schabi.newpipe.compose.theme.AppTheme
import org.schabi.newpipe.compose.util.determineItemViewMode
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.info_list.ItemViewMode
import org.schabi.newpipe.util.KEY_SERVICE_ID
import org.schabi.newpipe.util.KEY_URL
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.viewmodels.PlaylistViewModel
@Composable
@ -31,18 +42,50 @@ fun Playlist(playlistViewModel: PlaylistViewModel = viewModel()) {
val streams = playlistViewModel.streamItems.collectAsLazyPagingItems()
val totalDuration = streams.itemSnapshotList.sumOf { it!!.duration }
val context = LocalContext.current
val onClick = { stream: StreamInfoItem ->
NavigationHelper.openVideoDetailFragment(
context, (context as FragmentActivity).supportFragmentManager,
stream.serviceId, stream.url, stream.name, null, false
)
}
playlistInfo?.let {
Surface(color = MaterialTheme.colorScheme.background) {
val gridState = rememberLazyGridState()
val mode = determineItemViewMode()
LazyVerticalGridScrollbar(state = gridState) {
LazyVerticalGrid(state = gridState, columns = GridCells.Adaptive(164.dp)) {
item(span = { GridItemSpan(maxLineSpan) }) {
PlaylistHeader(playlistInfo = it, totalDuration = totalDuration)
if (mode == ItemViewMode.GRID) {
val gridState = rememberLazyGridState()
LazyVerticalGridScrollbar(state = gridState) {
LazyVerticalGrid(state = gridState, columns = GridCells.Adaptive(250.dp)) {
item(span = { GridItemSpan(maxLineSpan) }) {
PlaylistHeader(playlistInfo = it, totalDuration = totalDuration)
}
items(streams.itemCount) {
StreamGridItem(streams[it]!!, onClick)
}
}
}
} else {
// Card or list views
val listState = rememberLazyListState()
items(streams.itemCount) {
StreamGridItem(streams[it]!!)
LazyColumnScrollbar(state = listState) {
LazyColumn(state = listState) {
item {
PlaylistHeader(playlistInfo = it, totalDuration = totalDuration)
}
items(streams.itemCount) {
val stream = streams[it]!!
if (mode == ItemViewMode.CARD) {
StreamCardItem(stream, onClick)
} else {
StreamListItem(stream, onClick)
}
}
}
}
}

View File

@ -1,7 +1,9 @@
package org.schabi.newpipe.compose.playlist
import android.content.res.Configuration
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -22,6 +24,7 @@ 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.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -65,9 +68,9 @@ fun PlaylistHeader(playlistInfo: PlaylistInfo, totalDuration: Long) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.apply {
modifier = Modifier.let {
if (playlistInfo.uploaderName != null && playlistInfo.uploaderUrl != null) {
clickable {
it.clickable {
try {
NavigationHelper.openChannelFragment(
(context as FragmentActivity).supportFragmentManager,
@ -78,11 +81,13 @@ fun PlaylistHeader(playlistInfo: PlaylistInfo, totalDuration: Long) {
ErrorUtil.showUiErrorSnackbar(context, "Opening channel fragment", e)
}
}
}
} else it
}
) {
val imageModifier = Modifier
.size(24.dp)
.border(BorderStroke(1.dp, Color.White), CircleShape)
.padding(1.dp)
.clip(CircleShape)
val isMix = YoutubeParsingHelper.isYoutubeMixId(playlistInfo.id) ||
YoutubeParsingHelper.isYoutubeMusicMixId(playlistInfo.id)

View File

@ -0,0 +1,70 @@
package org.schabi.newpipe.compose.stream
import android.content.res.Configuration
import androidx.compose.foundation.clickable
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.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import org.schabi.newpipe.compose.theme.AppTheme
import org.schabi.newpipe.extractor.stream.StreamInfoItem
@Composable
fun StreamCardItem(stream: StreamInfoItem, onClick: (StreamInfoItem) -> Unit) {
Column(
modifier = Modifier
.clickable(onClick = { onClick(stream) })
.padding(top = 12.dp, start = 2.dp, end = 2.dp)
) {
StreamThumbnail(
stream = stream,
modifier = Modifier.fillMaxWidth(),
contentScale = ContentScale.FillWidth
)
Column(modifier = Modifier.padding(10.dp)) {
Text(
text = stream.name,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
maxLines = 2
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = stream.uploaderName.orEmpty(), style = MaterialTheme.typography.bodySmall)
Text(
text = getStreamInfoDetail(stream),
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun StreamCardItemPreview(
@PreviewParameter(StreamItemPreviewProvider::class) stream: StreamInfoItem
) {
AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
StreamCardItem(stream) {}
}
}
}

View File

@ -1,6 +1,5 @@
package org.schabi.newpipe.compose.stream
import android.content.Context
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@ -11,47 +10,21 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
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.fragment.app.FragmentActivity
import coil.compose.AsyncImage
import org.schabi.newpipe.R
import org.schabi.newpipe.compose.theme.AppTheme
import org.schabi.newpipe.extractor.Image
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NO_SERVICE_ID
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.image.ImageStrategy
import java.util.concurrent.TimeUnit
@Composable
fun StreamGridItem(stream: StreamInfoItem) {
val context = LocalContext.current
fun StreamGridItem(stream: StreamInfoItem, onClick: (StreamInfoItem) -> Unit) {
Column(
modifier = Modifier
.clickable {
NavigationHelper.openVideoDetailFragment(
context, (context as FragmentActivity).supportFragmentManager,
stream.serviceId, stream.url, stream.name, null, false
)
}
.clickable(onClick = { onClick(stream) })
.padding(12.dp)
) {
AsyncImage(
model = ImageStrategy.choosePreferredImage(stream.thumbnails),
contentDescription = null,
placeholder = painterResource(R.drawable.placeholder_thumbnail_video),
error = painterResource(R.drawable.placeholder_thumbnail_video),
modifier = Modifier.size(width = 164.dp, height = 92.dp)
)
StreamThumbnail(stream = stream, modifier = Modifier.size(width = 246.dp, height = 138.dp))
Text(
text = stream.name,
@ -62,60 +35,13 @@ fun StreamGridItem(stream: StreamInfoItem) {
Text(text = stream.uploaderName.orEmpty(), style = MaterialTheme.typography.bodySmall)
Text(text = getStreamInfoDetail(context, stream), style = MaterialTheme.typography.bodySmall)
Text(
text = getStreamInfoDetail(stream),
style = MaterialTheme.typography.bodySmall
)
}
}
private fun getStreamInfoDetail(context: Context, stream: StreamInfoItem): String {
val views = if (stream.viewCount >= 0) {
when (stream.streamType) {
StreamType.AUDIO_LIVE_STREAM -> Localization.listeningCount(context, stream.viewCount)
StreamType.LIVE_STREAM -> Localization.shortWatchingCount(context, stream.viewCount)
else -> Localization.shortViewCount(context, stream.viewCount)
}
} else {
""
}
val date =
Localization.relativeTimeOrTextual(context, stream.uploadDate, stream.textualUploadDate)
return if (views.isEmpty()) {
date
} else if (date.isNullOrEmpty()) {
views
} else {
"$views$date"
}
}
fun StreamInfoItem(
serviceId: Int = NO_SERVICE_ID,
url: String = "",
name: String = "Stream",
streamType: StreamType,
uploaderName: String? = "Uploader",
uploaderUrl: String? = null,
uploaderAvatars: List<Image> = emptyList(),
duration: Long = TimeUnit.HOURS.toSeconds(1),
viewCount: Long = 10,
textualUploadDate: String = "1 month ago"
) = StreamInfoItem(serviceId, url, name, streamType).apply {
this.uploaderName = uploaderName
this.uploaderUrl = uploaderUrl
this.uploaderAvatars = uploaderAvatars
this.duration = duration
this.viewCount = viewCount
this.textualUploadDate = textualUploadDate
}
private class StreamItemPreviewProvider : PreviewParameterProvider<StreamInfoItem> {
override val values = sequenceOf(
StreamInfoItem(streamType = StreamType.NONE),
StreamInfoItem(streamType = StreamType.LIVE_STREAM),
StreamInfoItem(streamType = StreamType.AUDIO_LIVE_STREAM),
)
}
@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
@ -124,7 +50,7 @@ private fun StreamGridItemPreview(
) {
AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
StreamGridItem(stream)
StreamGridItem(stream, onClick = {})
}
}
}

View File

@ -0,0 +1,65 @@
package org.schabi.newpipe.compose.stream
import android.content.res.Configuration
import androidx.compose.foundation.clickable
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.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import org.schabi.newpipe.compose.theme.AppTheme
import org.schabi.newpipe.extractor.stream.StreamInfoItem
@Composable
fun StreamListItem(stream: StreamInfoItem, onClick: (StreamInfoItem) -> Unit) {
Row(
modifier = Modifier
.clickable(onClick = { onClick(stream) })
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
StreamThumbnail(stream = stream, modifier = Modifier.size(width = 98.dp, height = 55.dp))
Column {
Text(
text = stream.name,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
maxLines = 1
)
Text(text = stream.uploaderName.orEmpty(), style = MaterialTheme.typography.bodySmall)
Text(
text = getStreamInfoDetail(stream),
style = MaterialTheme.typography.bodySmall
)
}
}
}
@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun StreamListItemPreview(
@PreviewParameter(StreamItemPreviewProvider::class) stream: StreamInfoItem
) {
AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
StreamListItem(stream, onClick = {})
}
}
}

View File

@ -0,0 +1,44 @@
package org.schabi.newpipe.compose.stream
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.image.ImageStrategy
@Composable
fun StreamThumbnail(
stream: StreamInfoItem,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
) {
Box(modifier = modifier, contentAlignment = Alignment.BottomEnd) {
AsyncImage(
model = ImageStrategy.choosePreferredImage(stream.thumbnails),
contentDescription = null,
placeholder = painterResource(R.drawable.placeholder_thumbnail_video),
error = painterResource(R.drawable.placeholder_thumbnail_video),
contentScale = contentScale,
modifier = modifier
)
Text(
text = Localization.getDurationString(stream.duration),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(2.dp)
.background(Color.Black.copy(alpha = 0.5f))
)
}
}

View File

@ -0,0 +1,68 @@
package org.schabi.newpipe.compose.stream
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import org.schabi.newpipe.extractor.Image
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NO_SERVICE_ID
import java.util.concurrent.TimeUnit
fun StreamInfoItem(
serviceId: Int = NO_SERVICE_ID,
url: String = "",
name: String = "Stream",
streamType: StreamType,
uploaderName: String? = "Uploader",
uploaderUrl: String? = null,
uploaderAvatars: List<Image> = emptyList(),
duration: Long = TimeUnit.HOURS.toSeconds(1),
viewCount: Long = 10,
textualUploadDate: String = "1 month ago"
) = StreamInfoItem(serviceId, url, name, streamType).apply {
this.uploaderName = uploaderName
this.uploaderUrl = uploaderUrl
this.uploaderAvatars = uploaderAvatars
this.duration = duration
this.viewCount = viewCount
this.textualUploadDate = textualUploadDate
}
@Composable
internal fun getStreamInfoDetail(stream: StreamInfoItem): String {
val context = LocalContext.current
return rememberSaveable(stream) {
val count = stream.viewCount
val views = if (count >= 0) {
when (stream.streamType) {
StreamType.AUDIO_LIVE_STREAM -> Localization.listeningCount(context, count)
StreamType.LIVE_STREAM -> Localization.shortWatchingCount(context, count)
else -> Localization.shortViewCount(context, count)
}
} else {
""
}
val date =
Localization.relativeTimeOrTextual(context, stream.uploadDate, stream.textualUploadDate)
if (views.isEmpty()) {
date
} else if (date.isNullOrEmpty()) {
views
} else {
"$views$date"
}
}
}
internal class StreamItemPreviewProvider : PreviewParameterProvider<StreamInfoItem> {
override val values = sequenceOf(
StreamInfoItem(streamType = StreamType.NONE),
StreamInfoItem(streamType = StreamType.LIVE_STREAM),
StreamInfoItem(streamType = StreamType.AUDIO_LIVE_STREAM),
)
}

View File

@ -2,7 +2,6 @@ package org.schabi.newpipe.fragments.list.playlist
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
@ -15,13 +14,12 @@ class PlaylistFragment2 : Fragment() {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View =
ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AppTheme {
Playlist()
}
) = ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AppTheme {
Playlist()
}
}
}
}

View File

@ -4,10 +4,6 @@ package org.schabi.newpipe.info_list;
* Item view mode for streams & playlist listing screens.
*/
public enum ItemViewMode {
/**
* Default mode.
*/
AUTO,
/**
* Full width list item with thumb on the left and two line title & uploader in right.
*/

View File

@ -0,0 +1,34 @@
package org.schabi.newpipe.compose.util
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.preference.PreferenceManager
import androidx.window.core.layout.WindowWidthSizeClass
import org.schabi.newpipe.R
import org.schabi.newpipe.info_list.ItemViewMode
@Composable
fun determineItemViewMode(): ItemViewMode {
val context = LocalContext.current
val listMode = PreferenceManager.getDefaultSharedPreferences(context)
.getString(
context.getString(R.string.list_view_mode_key),
context.getString(R.string.list_view_mode_value)
)
return when (listMode) {
context.getString(R.string.list_view_mode_list_key) -> ItemViewMode.LIST
context.getString(R.string.list_view_mode_grid_key) -> ItemViewMode.GRID
context.getString(R.string.list_view_mode_card_key) -> ItemViewMode.CARD
else -> {
// Auto mode - evaluate whether to use Grid based on screen real estate.
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
if (windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED) {
ItemViewMode.GRID
} else {
ItemViewMode.LIST
}
}
}
}