From 6d2446bb54cba2778ddef190b24d93bd6c3ab346 Mon Sep 17 00:00:00 2001 From: Hassan Date: Sun, 26 Jul 2026 21:07:37 +0300 Subject: [PATCH] feat(details): add ascending and descending episode ordering --- .../composeResources/values-ar/strings.xml | 5 + .../composeResources/values/strings.xml | 2 + .../app/features/details/EpisodeSortOrder.kt | 32 +++++ .../details/components/DetailSeriesContent.kt | 94 +++++++++++-- .../features/details/EpisodeSortOrderTest.kt | 127 ++++++++++++++++++ 5 files changed, 252 insertions(+), 8 deletions(-) create mode 100644 composeApp/src/commonMain/composeResources/values-ar/strings.xml create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/EpisodeSortOrder.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/EpisodeSortOrderTest.kt diff --git a/composeApp/src/commonMain/composeResources/values-ar/strings.xml b/composeApp/src/commonMain/composeResources/values-ar/strings.xml new file mode 100644 index 00000000..87ce0743 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-ar/strings.xml @@ -0,0 +1,5 @@ + + + ترتيب الحلقات تنازليًا + ترتيب الحلقات تصاعديًا + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index affcc835..0d44142a 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -2055,6 +2055,8 @@ About Loading subtitles from addons... Downloading subtitles... + Sort episodes descending + Sort episodes ascending %d title %d titles diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/EpisodeSortOrder.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/EpisodeSortOrder.kt new file mode 100644 index 00000000..44624287 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/EpisodeSortOrder.kt @@ -0,0 +1,32 @@ +package com.nuvio.app.features.details + +internal enum class EpisodeSortOrder { + Ascending, + Descending, + ; + + fun toggled(): EpisodeSortOrder = when (this) { + Ascending -> Descending + Descending -> Ascending + } +} + +internal fun List.orderedForEpisodeDisplay( + order: EpisodeSortOrder, +): List = withIndex() + .sortedWith { left, right -> + val leftNumber = left.value.validEpisodeNumber() + val rightNumber = right.value.validEpisodeNumber() + when { + leftNumber == null && rightNumber == null -> left.index.compareTo(right.index) + leftNumber == null -> 1 + rightNumber == null -> -1 + leftNumber == rightNumber -> left.index.compareTo(right.index) + order == EpisodeSortOrder.Ascending -> leftNumber.compareTo(rightNumber) + else -> rightNumber.compareTo(leftNumber) + } + } + .map(IndexedValue::value) + +private fun MetaVideo.validEpisodeNumber(): Int? = + episode?.takeIf { it > 0 } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index 71ba2ede..3f8f3b29 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -36,6 +36,10 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowDownward +import androidx.compose.material.icons.rounded.ArrowUpward +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -69,6 +73,7 @@ import com.nuvio.app.core.ui.NuvioProgressBar import com.nuvio.app.core.ui.nuvioCardDepth import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed import com.nuvio.app.core.ui.posterCardClickable +import com.nuvio.app.features.details.EpisodeSortOrder import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaEpisodeCardStyle import com.nuvio.app.features.details.MetaVideo @@ -77,6 +82,7 @@ import com.nuvio.app.features.details.SeasonViewModeStorage import com.nuvio.app.features.details.formatRuntimeFromMinutes import com.nuvio.app.features.details.metaVideoSeasonEpisodeComparator import com.nuvio.app.features.details.normalizeSeasonNumber +import com.nuvio.app.features.details.orderedForEpisodeDisplay import com.nuvio.app.features.details.seasonSortKey import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.buildPlaybackVideoId @@ -175,6 +181,12 @@ fun DetailSeriesContent( val currentSeason = selectedSeasonOverride ?.takeIf { it in groupedEpisodes } ?: defaultSeason + var isEpisodeOrderDescending by rememberSaveable(meta.id) { mutableStateOf(false) } + val episodeSortOrder = if (isEpisodeOrderDescending) { + EpisodeSortOrder.Descending + } else { + EpisodeSortOrder.Ascending + } var seasonViewMode by remember { mutableStateOf(SeasonViewModeStorage.load() ?: SeasonViewMode.Posters) @@ -284,13 +296,34 @@ fun DetailSeriesContent( Column( verticalArrangement = Arrangement.spacedBy(16.dp), ) { - DetailSectionTitle( - title = sectionTitle, - ) - val seasonEpisodes = groupedEpisodes.getValue(seasonForContent) + val seasonEpisodes = remember(groupedEpisodes, seasonForContent, episodeSortOrder) { + groupedEpisodes + .getValue(seasonForContent) + .orderedForEpisodeDisplay(episodeSortOrder) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + DetailSectionTitle( + title = sectionTitle, + fullWidth = false, + modifier = Modifier.weight(1f), + ) + if (meta.type == "series" && seasonEpisodes.size > 1) { + EpisodeSortToggle( + order = episodeSortOrder, + onClick = { + isEpisodeOrderDescending = !isEpisodeOrderDescending + }, + ) + } + } if (episodeCardStyle == MetaEpisodeCardStyle.Horizontal) { EpisodeHorizontalRow( episodes = seasonEpisodes, + sortOrder = episodeSortOrder, maxWidthDp = containerWidthDp, horizontalScrollPadding = horizontalScrollPadding, parentMetaId = meta.id, @@ -341,6 +374,44 @@ fun DetailSeriesContent( } } +@Composable +private fun EpisodeSortToggle( + order: EpisodeSortOrder, + onClick: () -> Unit, +) { + val sortsDescending = order == EpisodeSortOrder.Ascending + val actionDescription = if (sortsDescending) { + stringResource(Res.string.episodes_sort_descending_action) + } else { + stringResource(Res.string.episodes_sort_ascending_action) + } + + Box( + modifier = Modifier + .size(38.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.48f)) + .border( + width = 1.dp, + color = Color.White.copy(alpha = 0.16f), + shape = RoundedCornerShape(10.dp), + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (sortsDescending) { + Icons.Rounded.ArrowDownward + } else { + Icons.Rounded.ArrowUpward + }, + contentDescription = actionDescription, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } +} + @Composable private fun SeasonViewModeToggle( mode: SeasonViewMode, @@ -593,6 +664,7 @@ private fun SeasonPosterButton( @Composable private fun EpisodeHorizontalRow( episodes: List, + sortOrder: EpisodeSortOrder, maxWidthDp: Float, horizontalScrollPadding: Dp, parentMetaId: String, @@ -608,15 +680,20 @@ private fun EpisodeHorizontalRow( ) { val rowMetrics = rememberEpisodeHorizontalCardMetrics(maxWidthDp) val listState = rememberLazyListState() - var hasPositioned by remember(episodes) { mutableStateOf(false) } + var hasPositioned by remember { mutableStateOf(false) } + var previousSortOrder by remember { mutableStateOf(sortOrder) } - LaunchedEffect(episodes, preferredEpisodeNumber) { + LaunchedEffect(episodes, preferredEpisodeNumber, sortOrder) { + val sortOrderChanged = sortOrder != previousSortOrder val targetIndex = if (preferredEpisodeNumber != null) { episodes.indexOfFirst { it.episode == preferredEpisodeNumber } } else { -1 } - if (targetIndex >= 0) { + if (sortOrderChanged) { + listState.animateScrollToItem(0) + hasPositioned = true + } else if (targetIndex >= 0) { if (hasPositioned) { listState.animateScrollToItem(targetIndex) } else { @@ -624,6 +701,7 @@ private fun EpisodeHorizontalRow( hasPositioned = true } } + previousSortOrder = sortOrder } LazyRow( @@ -639,7 +717,7 @@ private fun EpisodeHorizontalRow( ) { itemsIndexed( items = episodes, - key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" }, + key = { _, episode -> "${episode.season}:${episode.episode}:${episode.id}" }, ) { _, episode -> val episodeVideoId = buildPlaybackVideoId( parentMetaId = parentMetaId, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/EpisodeSortOrderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/EpisodeSortOrderTest.kt new file mode 100644 index 00000000..c536a9e0 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/EpisodeSortOrderTest.kt @@ -0,0 +1,127 @@ +package com.nuvio.app.features.details + +import com.nuvio.app.features.watchprogress.buildPlaybackVideoId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class EpisodeSortOrderTest { + + @Test + fun ordersNumberedEpisodesAscendingAndDescending() { + val episodes = listOf(episode(1), episode(2), episode(3), episode(4)) + + assertEquals( + listOf(1, 2, 3, 4), + episodes.orderedForEpisodeDisplay(EpisodeSortOrder.Ascending).map(MetaVideo::episode), + ) + assertEquals( + listOf(4, 3, 2, 1), + episodes.orderedForEpisodeDisplay(EpisodeSortOrder.Descending).map(MetaVideo::episode), + ) + } + + @Test + fun unknownAndInvalidNumbersStayAtTheEndInBothDirections() { + val episodes = listOf(episode(1), episode(2), episode(null), episode(4)) + + assertEquals( + listOf(1, 2, 4, null), + episodes.orderedForEpisodeDisplay(EpisodeSortOrder.Ascending).map(MetaVideo::episode), + ) + assertEquals( + listOf(4, 2, 1, null), + episodes.orderedForEpisodeDisplay(EpisodeSortOrder.Descending).map(MetaVideo::episode), + ) + + val invalidNumbers = listOf(episode(2), episode(0), episode(-1), episode(1)) + assertEquals( + listOf(1, 2, 0, -1), + invalidNumbers.orderedForEpisodeDisplay(EpisodeSortOrder.Ascending).map(MetaVideo::episode), + ) + assertEquals( + listOf(2, 1, 0, -1), + invalidNumbers.orderedForEpisodeDisplay(EpisodeSortOrder.Descending).map(MetaVideo::episode), + ) + } + + @Test + fun sortsSpecialsWithoutChangingSeasonOrderOrSourceList() { + val source = listOf( + episode(number = 2, season = 0), + episode(number = 1, season = 0), + episode(number = 1, season = 1), + episode(number = 2, season = 1), + ) + val originalIds = source.map(MetaVideo::id) + val bySeason = source.groupBy { normalizeSeasonNumber(it.season) } + + assertEquals( + listOf(2, 1), + bySeason.getValue(0) + .orderedForEpisodeDisplay(EpisodeSortOrder.Descending) + .map(MetaVideo::episode), + ) + assertEquals(listOf(0, 1), bySeason.keys.toList()) + assertEquals(originalIds, source.map(MetaVideo::id)) + } + + @Test + fun selectedOrderAppliesWhenChangingSeasonsAndRepeatedlyToggling() { + val bySeason = listOf( + episode(number = 1, season = 1), + episode(number = 2, season = 1), + episode(number = 1, season = 2), + episode(number = 2, season = 2), + ).groupBy { normalizeSeasonNumber(it.season) } + val descending = EpisodeSortOrder.Ascending.toggled() + + assertEquals( + listOf(2, 1), + bySeason.getValue(1).orderedForEpisodeDisplay(descending).map(MetaVideo::episode), + ) + assertEquals( + listOf(2, 1), + bySeason.getValue(2).orderedForEpisodeDisplay(descending).map(MetaVideo::episode), + ) + assertEquals( + EpisodeSortOrder.Ascending, + descending.toggled(), + ) + } + + @Test + fun reversingPreservesEpisodeIdentityAndProgressLookup() { + val first = episode(1) + val fourth = episode(4) + val parentMetaId = "series-fixture" + val progressByVideoId = mapOf( + first.playbackVideoId(parentMetaId) to 0.25f, + fourth.playbackVideoId(parentMetaId) to 0.75f, + ) + + val reversed = listOf(first, fourth).orderedForEpisodeDisplay(EpisodeSortOrder.Descending) + + assertSame(fourth, reversed.first()) + assertEquals("season-1-episode-4", reversed.first().id) + assertEquals(0.75f, progressByVideoId[reversed.first().playbackVideoId(parentMetaId)]) + } + + private fun MetaVideo.playbackVideoId(parentMetaId: String): String = + buildPlaybackVideoId( + parentMetaId = parentMetaId, + seasonNumber = season, + episodeNumber = episode, + fallbackVideoId = id, + ) + + private fun episode( + number: Int?, + season: Int = 1, + ) = MetaVideo( + id = "season-$season-episode-$number", + title = "Episode $number", + season = season, + episode = number, + ) +}