mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
feature: Add next up section to homepage
This commit is contained in:
@@ -49,6 +49,7 @@ fun HomePage(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
val continueWatching = viewModel.continueWatching.collectAsState()
|
val continueWatching = viewModel.continueWatching.collectAsState()
|
||||||
|
val nextUp = viewModel.nextUp.collectAsState()
|
||||||
val latestLibraryContent = viewModel.latestLibraryContent.collectAsState()
|
val latestLibraryContent = viewModel.latestLibraryContent.collectAsState()
|
||||||
|
|
||||||
LifecycleResumeEffect(Unit) {
|
LifecycleResumeEffect(Unit) {
|
||||||
@@ -92,6 +93,7 @@ fun HomePage(
|
|||||||
libraries = libraries,
|
libraries = libraries,
|
||||||
libraryContent = latestLibraryContent.value,
|
libraryContent = latestLibraryContent.value,
|
||||||
continueWatching = continueWatching.value,
|
continueWatching = continueWatching.value,
|
||||||
|
nextUp = nextUp.value,
|
||||||
onMovieSelected = viewModel::onMovieSelected,
|
onMovieSelected = viewModel::onMovieSelected,
|
||||||
onSeriesSelected = viewModel::onSeriesSelected,
|
onSeriesSelected = viewModel::onSeriesSelected,
|
||||||
onEpisodeSelected = viewModel::onEpisodeSelected,
|
onEpisodeSelected = viewModel::onEpisodeSelected,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import hu.bbara.purefin.app.home.ui.ContinueWatchingItem
|
import hu.bbara.purefin.app.home.ui.ContinueWatchingItem
|
||||||
import hu.bbara.purefin.app.home.ui.HomeNavItem
|
import hu.bbara.purefin.app.home.ui.HomeNavItem
|
||||||
import hu.bbara.purefin.app.home.ui.LibraryItem
|
import hu.bbara.purefin.app.home.ui.LibraryItem
|
||||||
|
import hu.bbara.purefin.app.home.ui.NextUpItem
|
||||||
import hu.bbara.purefin.app.home.ui.PosterItem
|
import hu.bbara.purefin.app.home.ui.PosterItem
|
||||||
import hu.bbara.purefin.client.JellyfinApiClient
|
import hu.bbara.purefin.client.JellyfinApiClient
|
||||||
import hu.bbara.purefin.data.InMemoryMediaRepository
|
import hu.bbara.purefin.data.InMemoryMediaRepository
|
||||||
@@ -77,6 +78,24 @@ class HomePageViewModel @Inject constructor(
|
|||||||
initialValue = emptyList()
|
initialValue = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val nextUp = combine(
|
||||||
|
mediaRepository.nextUp,
|
||||||
|
mediaRepository.episodes
|
||||||
|
) { list, episodesMap ->
|
||||||
|
list.mapNotNull { media ->
|
||||||
|
when (media) {
|
||||||
|
is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let {
|
||||||
|
NextUpItem(episode = it)
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}.distinctBy { it.id }
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
val latestLibraryContent = combine(
|
val latestLibraryContent = combine(
|
||||||
mediaRepository.latestLibraryContent,
|
mediaRepository.latestLibraryContent,
|
||||||
mediaRepository.movies,
|
mediaRepository.movies,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ fun HomeContent(
|
|||||||
libraries: List<LibraryItem>,
|
libraries: List<LibraryItem>,
|
||||||
libraryContent: Map<UUID, List<PosterItem>>,
|
libraryContent: Map<UUID, List<PosterItem>>,
|
||||||
continueWatching: List<ContinueWatchingItem>,
|
continueWatching: List<ContinueWatchingItem>,
|
||||||
|
nextUp: List<NextUpItem>,
|
||||||
onMovieSelected: (UUID) -> Unit,
|
onMovieSelected: (UUID) -> Unit,
|
||||||
onSeriesSelected: (UUID) -> Unit,
|
onSeriesSelected: (UUID) -> Unit,
|
||||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||||
@@ -37,6 +38,12 @@ fun HomeContent(
|
|||||||
onEpisodeSelected = onEpisodeSelected
|
onEpisodeSelected = onEpisodeSelected
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
NextUpSection(
|
||||||
|
items = nextUp,
|
||||||
|
onEpisodeSelected = onEpisodeSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
items(
|
items(
|
||||||
items = libraries.filter { libraryContent[it.id]?.isEmpty() != true },
|
items = libraries.filter { libraryContent[it.id]?.isEmpty() != true },
|
||||||
key = { it.id }
|
key = { it.id }
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ data class ContinueWatchingItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class NextUpItem(
|
||||||
|
val episode: Episode
|
||||||
|
) {
|
||||||
|
val id: UUID = episode.id
|
||||||
|
val primaryText: String = episode.title
|
||||||
|
val secondaryText: String = episode.releaseDate
|
||||||
|
}
|
||||||
|
|
||||||
data class LibraryItem(
|
data class LibraryItem(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val name: String,
|
val name: String,
|
||||||
|
|||||||
@@ -188,6 +188,126 @@ fun ContinueWatchingCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NextUpSection(
|
||||||
|
items: List<NextUpItem>,
|
||||||
|
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
SectionHeader(
|
||||||
|
title = "Next Up",
|
||||||
|
action = null
|
||||||
|
)
|
||||||
|
LazyRow(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = items, key = { it.id }) { item ->
|
||||||
|
NextUpCard(
|
||||||
|
item = item,
|
||||||
|
onEpisodeSelected = onEpisodeSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NextUpCard(
|
||||||
|
item: NextUpItem,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
val imageUrl = item.episode.heroImageUrl
|
||||||
|
|
||||||
|
val cardWidth = 280.dp
|
||||||
|
val cardHeight = cardWidth * 9 / 16
|
||||||
|
|
||||||
|
fun openItem(item: NextUpItem) {
|
||||||
|
val episode = item.episode
|
||||||
|
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
val imageRequest = ImageRequest.Builder(context)
|
||||||
|
.data(imageUrl)
|
||||||
|
.size(with(density) { cardWidth.roundToPx() }, with(density) { cardHeight.roundToPx() })
|
||||||
|
.build()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.width(cardWidth)
|
||||||
|
.wrapContentHeight()
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(cardWidth)
|
||||||
|
.aspectRatio(16f / 9f)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(16.dp))
|
||||||
|
.background(scheme.surfaceVariant)
|
||||||
|
) {
|
||||||
|
PurefinAsyncImage(
|
||||||
|
model = imageRequest,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clickable {
|
||||||
|
openItem(item)
|
||||||
|
},
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(end = 8.dp, bottom = 16.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(scheme.secondary)
|
||||||
|
.size(36.dp),
|
||||||
|
onClick = {
|
||||||
|
val intent = Intent(context, PlayerActivity::class.java)
|
||||||
|
intent.putExtra("MEDIA_ID", item.id.toString())
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
colors = IconButtonColors(
|
||||||
|
containerColor = scheme.secondary,
|
||||||
|
contentColor = scheme.onSecondary,
|
||||||
|
disabledContainerColor = scheme.secondary,
|
||||||
|
disabledContentColor = scheme.onSecondary
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.PlayArrow,
|
||||||
|
contentDescription = "Play",
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = item.primaryText,
|
||||||
|
color = scheme.onBackground,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = item.secondaryText,
|
||||||
|
color = scheme.onSurfaceVariant,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryPosterSection(
|
fun LibraryPosterSection(
|
||||||
title: String,
|
title: String,
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ class InMemoryMediaRepository @Inject constructor(
|
|||||||
private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
|
private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
|
||||||
val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow()
|
val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow()
|
||||||
|
|
||||||
|
private val _nextUp: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
|
||||||
|
val nextUp: StateFlow<List<Media>> = _nextUp.asStateFlow()
|
||||||
|
|
||||||
private val _latestLibraryContent: MutableStateFlow<Map<UUID, List<Media>>> = MutableStateFlow(emptyMap())
|
private val _latestLibraryContent: MutableStateFlow<Map<UUID, List<Media>>> = MutableStateFlow(emptyMap())
|
||||||
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = _latestLibraryContent.asStateFlow()
|
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = _latestLibraryContent.asStateFlow()
|
||||||
|
|
||||||
@@ -82,6 +85,7 @@ class InMemoryMediaRepository @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
loadLibraries()
|
loadLibraries()
|
||||||
loadContinueWatching()
|
loadContinueWatching()
|
||||||
|
loadNextUp()
|
||||||
loadLatestLibraryContent()
|
loadLatestLibraryContent()
|
||||||
_state.value = MediaRepositoryState.Ready
|
_state.value = MediaRepositoryState.Ready
|
||||||
ready.complete(Unit)
|
ready.complete(Unit)
|
||||||
@@ -171,6 +175,23 @@ class InMemoryMediaRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun loadNextUp() {
|
||||||
|
val nextUpItems = jellyfinApiClient.getNextUpEpisodes()
|
||||||
|
val items = nextUpItems.map { item ->
|
||||||
|
Media.EpisodeMedia(
|
||||||
|
episodeId = item.id,
|
||||||
|
seriesId = item.seriesId!!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_nextUp.value = items
|
||||||
|
|
||||||
|
// Load episodes
|
||||||
|
nextUpItems.forEach { item ->
|
||||||
|
val episode = item.toEpisode(serverUrl())
|
||||||
|
localDataSource.saveEpisode(episode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun loadLatestLibraryContent() {
|
suspend fun loadLatestLibraryContent() {
|
||||||
// TODO Make libraries accessible in a field or something that is not this ugly.
|
// TODO Make libraries accessible in a field or something that is not this ugly.
|
||||||
val librariesItem = jellyfinApiClient.getLibraries()
|
val librariesItem = jellyfinApiClient.getLibraries()
|
||||||
@@ -243,6 +264,7 @@ class InMemoryMediaRepository @Inject constructor(
|
|||||||
override suspend fun refreshHomeData() {
|
override suspend fun refreshHomeData() {
|
||||||
loadLibraries()
|
loadLibraries()
|
||||||
loadContinueWatching()
|
loadContinueWatching()
|
||||||
|
loadNextUp()
|
||||||
loadLatestLibraryContent()
|
loadLatestLibraryContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user