This commit is contained in:
i7xre 2026-07-29 06:01:26 +03:00 committed by GitHub
commit 0d573247f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 252 additions and 8 deletions

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="episodes_sort_descending_action">ترتيب الحلقات تنازليًا</string>
<string name="episodes_sort_ascending_action">ترتيب الحلقات تصاعديًا</string>
</resources>

View file

@ -2055,6 +2055,8 @@
<string name="entity_browse_about">About</string>
<string name="player_external_loading_subtitles">Loading subtitles from addons...</string>
<string name="player_external_downloading_subtitles">Downloading subtitles...</string>
<string name="episodes_sort_descending_action">Sort episodes descending</string>
<string name="episodes_sort_ascending_action">Sort episodes ascending</string>
<plurals name="entity_browse_title_count">
<item quantity="one">%d title</item>
<item quantity="other">%d titles</item>

View file

@ -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<MetaVideo>.orderedForEpisodeDisplay(
order: EpisodeSortOrder,
): List<MetaVideo> = 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<MetaVideo>::value)
private fun MetaVideo.validEpisodeNumber(): Int? =
episode?.takeIf { it > 0 }

View file

@ -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<MetaVideo>,
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,

View file

@ -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,
)
}