mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-03 10:10:04 +00:00
Merge 6d2446bb54 into 979d5680d4
This commit is contained in:
commit
0d573247f5
5 changed files with 252 additions and 8 deletions
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue