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 000000000..87ce0743d
--- /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 affcc835c..0d44142ab 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 000000000..446242870
--- /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 71ba2eded..3f8f3b29d 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 000000000..c536a9e0d
--- /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,
+ )
+}