Merge branch 'cmp-rewrite' of https://github.com/NuvioMedia/NuvioMobile into cmp-rewrite

This commit is contained in:
tapframe 2026-05-10 12:36:18 +05:30
commit 46a82dce9a
14 changed files with 1487 additions and 4 deletions

View file

@ -260,6 +260,7 @@ kotlin {
commonMain.dependencies {
implementation(libs.coil.compose)
implementation(libs.coil.network.ktor3)
implementation(libs.coil.svg)
implementation("dev.chrisbanes.haze:haze:1.7.2")
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)

View file

@ -9,4 +9,5 @@
<locale android:name="el"/>
<locale android:name="pl"/>
<locale android:name="de"/>
<locale android:name="cs"/>
</locale-config>

File diff suppressed because it is too large Load diff

View file

@ -541,6 +541,12 @@
<string name="settings_continue_watching_blur_next_up_title">Blur Unwatched in Continue Watching</string>
<string name="settings_continue_watching_show_unaired_next_up_description">Include upcoming episodes in Continue Watching before they air.</string>
<string name="settings_continue_watching_show_unaired_next_up_title">Show Unaired Next Up Episodes</string>
<string name="settings_continue_watching_section_sort_order">SORT ORDER</string>
<string name="settings_continue_watching_sort_mode_title">Sort Order</string>
<string name="settings_continue_watching_sort_mode_default">Default</string>
<string name="settings_continue_watching_sort_mode_default_desc">Sort all items by recency</string>
<string name="settings_continue_watching_sort_mode_streaming">Streaming Style</string>
<string name="settings_continue_watching_sort_mode_streaming_desc">Released items first, upcoming at the end</string>
<string name="settings_continue_watching_section_card_style">Poster Card Style</string>
<string name="settings_continue_watching_section_on_launch">ON LAUNCH</string>
<string name="settings_continue_watching_section_up_next_behavior">UP NEXT BEHAVIOR</string>

View file

@ -73,6 +73,7 @@ import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory
import coil3.request.CachePolicy
import coil3.request.crossfade
import coil3.svg.SvgDecoder
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
@ -304,6 +305,9 @@ fun App() {
.crossfade(true)
.diskCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.components {
add(SvgDecoder.Factory())
}
.configurePlatformImageLoader()
.build()
}

View file

@ -42,6 +42,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode
import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching
import com.nuvio.app.features.watchprogress.nextUpDismissKey
import com.nuvio.app.features.watchprogress.WatchProgressClock
@ -247,11 +248,14 @@ fun HomeScreen(
visibleContinueWatchingEntries,
cachedInProgressItems,
effectivNextUpItems,
continueWatchingPreferences.sortMode,
) {
buildHomeContinueWatchingItems(
visibleEntries = visibleContinueWatchingEntries,
cachedInProgressByVideoId = cachedInProgressItems,
nextUpItemsBySeries = effectivNextUpItems,
sortMode = continueWatchingPreferences.sortMode,
todayIsoDate = CurrentDateProvider.todayIsoDate(),
)
}
val availableManifests = remember(addonsUiState.addons) {
@ -639,6 +643,8 @@ internal fun buildHomeContinueWatchingItems(
visibleEntries: List<WatchProgressEntry>,
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
todayIsoDate: String = "",
): List<ContinueWatchingItem> {
val inProgressSeriesIds = visibleEntries
.asSequence()
@ -647,7 +653,7 @@ internal fun buildHomeContinueWatchingItems(
.filter(String::isNotBlank)
.toSet()
return buildList {
val candidates = buildList {
addAll(
visibleEntries.map { entry ->
val liveItem = entry.toContinueWatchingItem()
@ -669,13 +675,62 @@ internal fun buildHomeContinueWatchingItems(
},
)
}
// Deduplicate by series/content id first (order-stable)
val seen = mutableSetOf<String>()
val deduplicated = candidates
.sortedWith(
compareByDescending<HomeContinueWatchingCandidate> { it.lastUpdatedEpochMs }
.thenByDescending { it.isProgressEntry },
)
.filter { candidate -> candidate.item.shouldDisplayInContinueWatching() }
.distinctBy { candidate -> candidate.item.parentMetaId.ifBlank { candidate.item.videoId } }
.filter { candidate ->
val key = candidate.item.parentMetaId.ifBlank { candidate.item.videoId }
seen.add(key)
}
return when (sortMode) {
ContinueWatchingSortMode.DEFAULT -> deduplicated.map(HomeContinueWatchingCandidate::item)
ContinueWatchingSortMode.STREAMING_STYLE -> applyStreamingStyleSort(deduplicated, todayIsoDate)
}
}
private fun applyStreamingStyleSort(
candidates: List<HomeContinueWatchingCandidate>,
todayIsoDate: String,
): List<ContinueWatchingItem> {
val (released, unreleased) = candidates.partition { candidate ->
val item = candidate.item
if (!item.isNextUp) {
true // in-progress items are always "released"
} else {
val itemReleased = item.released
if (itemReleased.isNullOrBlank() || todayIsoDate.isBlank()) {
true // no date info → treat as released
} else {
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = itemReleased)
}
}
}
// Released: most recently watched first (already sorted by dedup pass)
val sortedReleased = released.map(HomeContinueWatchingCandidate::item)
// Unaired: soonest air date first; unknown dates go to the end
val sortedUnreleased = unreleased
.sortedWith { a, b ->
val dateA = a.item.released?.takeIf { it.isNotBlank() }
val dateB = b.item.released?.takeIf { it.isNotBlank() }
when {
dateA == null && dateB == null -> 0
dateA == null -> 1
dateB == null -> -1
else -> dateA.compareTo(dateB)
}
}
.map(HomeContinueWatchingCandidate::item)
return sortedReleased + sortedUnreleased
}
private data class CompletedSeriesCandidate(

View file

@ -10,6 +10,7 @@ import nuvio.composeapp.generated.resources.lang_turkish
import nuvio.composeapp.generated.resources.lang_italian
import nuvio.composeapp.generated.resources.lang_greek
import nuvio.composeapp.generated.resources.lang_polish
import nuvio.composeapp.generated.resources.lang_czech
import org.jetbrains.compose.resources.StringResource
enum class AppLanguage(
@ -25,6 +26,7 @@ enum class AppLanguage(
ITALIAN("it", Res.string.lang_italian),
GREEK("el", Res.string.lang_greek),
POLISH("pl", Res.string.lang_polish),
CZECH("cs", Res.string.lang_czech),
;
companion object {

View file

@ -5,18 +5,27 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@ -25,6 +34,7 @@ import androidx.compose.ui.unit.dp
import com.nuvio.app.features.home.components.ContinueWatchingStylePreview
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_description
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title
@ -34,10 +44,16 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_show_unai
import nuvio.composeapp.generated.resources.settings_continue_watching_show_unaired_next_up_title
import nuvio.composeapp.generated.resources.settings_continue_watching_section_card_style
import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch
import nuvio.composeapp.generated.resources.settings_continue_watching_section_sort_order
import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior
import nuvio.composeapp.generated.resources.settings_continue_watching_section_visibility
import nuvio.composeapp.generated.resources.settings_continue_watching_show_description
import nuvio.composeapp.generated.resources.settings_continue_watching_show_title
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_default
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_default_desc
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming_desc
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_title
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster_description
import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide
@ -58,6 +74,7 @@ internal fun LazyListScope.continueWatchingSettingsContent(
showUnairedNextUp: Boolean,
blurNextUp: Boolean,
showResumePromptOnLaunch: Boolean,
sortMode: ContinueWatchingSortMode,
) {
item {
SettingsSection(
@ -145,6 +162,39 @@ internal fun LazyListScope.continueWatchingSettingsContent(
}
}
}
item {
var showSortModeSheet by remember { mutableStateOf(false) }
SettingsSection(
title = stringResource(Res.string.settings_continue_watching_section_sort_order),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
val currentModeLabel = stringResource(
when (sortMode) {
ContinueWatchingSortMode.DEFAULT -> Res.string.settings_continue_watching_sort_mode_default
ContinueWatchingSortMode.STREAMING_STYLE -> Res.string.settings_continue_watching_sort_mode_streaming
}
)
SettingsNavigationRow(
title = stringResource(Res.string.settings_continue_watching_sort_mode_title),
description = currentModeLabel,
isTablet = isTablet,
onClick = { showSortModeSheet = true },
)
}
}
if (showSortModeSheet) {
ContinueWatchingSortModeDialog(
currentMode = sortMode,
onModeSelected = { mode ->
ContinueWatchingPreferencesRepository.setSortMode(mode)
showSortModeSheet = false
},
onDismiss = { showSortModeSheet = false },
)
}
}
}
@Composable
@ -250,3 +300,101 @@ private val ContinueWatchingSectionStyle.descriptionRes: StringResource
ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide_description
ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster_description
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ContinueWatchingSortModeDialog(
currentMode: ContinueWatchingSortMode,
onModeSelected: (ContinueWatchingSortMode) -> Unit,
onDismiss: () -> Unit,
) {
val options = listOf(
Triple(
ContinueWatchingSortMode.DEFAULT,
Res.string.settings_continue_watching_sort_mode_default,
Res.string.settings_continue_watching_sort_mode_default_desc,
),
Triple(
ContinueWatchingSortMode.STREAMING_STYLE,
Res.string.settings_continue_watching_sort_mode_streaming,
Res.string.settings_continue_watching_sort_mode_streaming_desc,
),
)
BasicAlertDialog(
onDismissRequest = onDismiss,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(Res.string.settings_continue_watching_sort_mode_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
options.forEach { (mode, titleRes, descriptionRes) ->
val isSelected = mode == currentMode
val containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onModeSelected(mode) },
shape = RoundedCornerShape(12.dp),
color = containerColor,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(titleRes),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(descriptionRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center,
) {
if (isSelected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
}
}
}
}

View file

@ -135,6 +135,7 @@ fun ContinueWatchingSettingsScreen(
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
sortMode = continueWatchingPreferencesUiState.sortMode,
)
}
}

View file

@ -509,6 +509,7 @@ private fun MobileSettingsScreen(
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
sortMode = continueWatchingPreferencesUiState.sortMode,
)
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
isTablet = false,
@ -866,6 +867,7 @@ private fun TabletSettingsScreen(
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
sortMode = continueWatchingPreferencesUiState.sortMode,
)
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
isTablet = true,

View file

@ -15,8 +15,7 @@ private const val COMMENTS_SORT = "likes"
private const val COMMENTS_LIMIT = 100
private const val COMMENTS_CACHE_TTL_MS = 10 * 60_000L
private val INLINE_SPOILER_REGEX = Regex(
"\\[spoiler\\].*?\\[/spoiler\\]",
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL),
"(?is)\\[spoiler\\].*?\\[/spoiler\\]"
)
private val INLINE_SPOILER_TAG_REGEX = Regex("\\[/?spoiler\\]", RegexOption.IGNORE_CASE)

View file

@ -22,6 +22,8 @@ private data class StoredContinueWatchingPreferences(
val blurNextUp: Boolean = false,
val dismissedNextUpKeys: Set<String> = emptySet(),
val showResumePromptOnLaunch: Boolean = true,
@SerialName("sort_mode")
val sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
)
object ContinueWatchingPreferencesRepository {
@ -97,6 +99,7 @@ object ContinueWatchingPreferencesRepository {
blurNextUp = stored.blurNextUp,
dismissedNextUpKeys = stored.dismissedNextUpKeys,
showResumePromptOnLaunch = stored.showResumePromptOnLaunch,
sortMode = stored.sortMode,
)
} else {
ContinueWatchingPreferencesUiState()
@ -155,6 +158,13 @@ object ContinueWatchingPreferencesRepository {
persist()
}
fun setSortMode(mode: ContinueWatchingSortMode) {
ensureLoaded()
if (_uiState.value.sortMode == mode) return
_uiState.value = _uiState.value.copy(sortMode = mode)
persist()
}
fun removeDismissedNextUpKeysForContent(contentId: String) {
ensureLoaded()
val normalizedContentId = contentId.trim()
@ -178,6 +188,7 @@ object ContinueWatchingPreferencesRepository {
blurNextUp = _uiState.value.blurNextUp,
dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys,
showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch,
sortMode = _uiState.value.sortMode,
),
),
)

View file

@ -17,6 +17,12 @@ enum class ContinueWatchingSectionStyle {
Poster,
}
@Serializable
enum class ContinueWatchingSortMode {
DEFAULT,
STREAMING_STYLE,
}
@Serializable
data class WatchProgressEntry(
val contentType: String,
@ -175,6 +181,7 @@ data class ContinueWatchingPreferencesUiState(
val blurNextUp: Boolean = false,
val dismissedNextUpKeys: Set<String> = emptySet(),
val showResumePromptOnLaunch: Boolean = true,
val sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
)
internal fun nextUpDismissKey(

View file

@ -51,6 +51,7 @@ compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-previ
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }