feat: show per-item progress during Nuvio account import

Surfaces which item is currently being imported (index/total/title)
under each import step on both mobile and TV auth screens, with a
checkmark for completed steps.
This commit is contained in:
KhooLy 2026-07-28 00:26:07 +03:00
parent c030fd66b2
commit 78569c8f15
8 changed files with 125 additions and 44 deletions

View file

@ -158,7 +158,7 @@ internal class AndroidAuthEffectHandler(
val profile = payload.parseProfile() ?: return failure(effect, "missing_profile")
if (payload.string("provider") == "nuvio") {
if (profile.nuvioAccessToken.isNullOrBlank()) return failure(effect, "missing_nuvio_token")
val imported = nuvioAccountImportCoordinator.sync(profile) {}
val imported = nuvioAccountImportCoordinator.sync(profile, onStep = {})
pluginRepositoryManager.importNuvioPlugins(imported.plugins)
return success(
effect,

View file

@ -207,12 +207,19 @@ class AndroidAuthDataSource(
email = session.user?.email ?: pendingNuvioEmail,
authKey = ""
)
val imported = nuvioCoordinator.import(baseProfile, session) { step ->
NUVIO_STEP_MAP[step]?.let { mapped -> state.update { it.copy(importSteps = it.importSteps + mapped) } }
}
val imported = nuvioCoordinator.import(
baseProfile,
session,
onStep = { step ->
NUVIO_STEP_MAP[step]?.let { mapped -> state.update { it.copy(importSteps = it.importSteps + mapped) } }
},
onItemProgress = { index, total, title ->
state.update { it.copy(importItemIndex = index, importItemTotal = total, importItemTitle = title) }
}
)
pluginRepositoryManager.importNuvioPlugins(imported.plugins)
pendingImportedProfile = imported.profile
state.update { it.copy(importDone = true) }
state.update { it.copy(importDone = true, importItemIndex = null, importItemTotal = null, importItemTitle = null) }
} catch (e: Exception) {
state.update { it.copy(stage = AuthStage.Nuvio, globalError = AppStrings.t(lang, "auth.error.network")) }
}

View file

@ -11,7 +11,9 @@
"calendar.no_releases_this_day": "No releases on this day.",
"notification.new_episode_released": "New Episode Released",
"notification.new_season_released": "New Season Released",
"notification.release_now_episode": "%s S%s:E%s Released now",
"notification.new_episode_body": "S%s:E%s is now available",
"notification.new_episode_body_titled": "S%s:E%s · %s is now available",
"notification.new_season_body": "Season %s is now available",
"notification.plugins_updated_title": "CloudStream Plugins Updated",
"notification.plugins_updated_body": "%s plugin(s) updated: %s",
"notification.plugins_update_failed_title": "CloudStream Plugin Update Failed",
@ -88,6 +90,7 @@
"auth.nuvio.import.progress": "Watch progress",
"auth.nuvio.import.history": "Watch history",
"auth.nuvio.import.collections": "Collections",
"auth.nuvio.import.item_progress": "[%s/%s] Importing %s…",
"login.loading": "Signing in...",
"login.qr_content_description": "QR login",
"login.instant_title": "Sign In Instantly",

View file

@ -11,7 +11,9 @@
"calendar.no_releases_this_day": "Bu günde yayın yok.",
"notification.new_episode_released": "Yeni Bölüm Yayınlandı",
"notification.new_season_released": "Yeni Sezon Yayınlandı",
"notification.release_now_episode": "%s S%s:E%s şimdi yayınlandı",
"notification.new_episode_body": "S%s:E%s şimdi yayında",
"notification.new_episode_body_titled": "S%s:E%s · %s şimdi yayında",
"notification.new_season_body": "%s. Sezon şimdi yayında",
"notification.plugins_updated_title": "CloudStream Eklentileri Güncellendi",
"notification.plugins_updated_body": "%s eklenti güncellendi: %s",
"notification.plugins_update_failed_title": "CloudStream Eklenti Güncellemesi Başarısız",
@ -88,6 +90,7 @@
"auth.nuvio.import.progress": "İzleme ilerlemesi",
"auth.nuvio.import.history": "İzleme geçmişi",
"auth.nuvio.import.collections": "Koleksiyonlar",
"auth.nuvio.import.item_progress": "[%s/%s] %s aktarılıyor…",
"login.loading": "Giriş Yapılıyor...",
"login.qr_content_description": "QR ile giriş",
"login.instant_title": "Anında Giriş Yap",

View file

@ -57,7 +57,11 @@ class NuvioAccountImportCoordinator(
nuvioService.signUp(NuvioCredentials(email, password))
}
suspend fun sync(profile: UserProfile, onStep: (NuvioImportStep) -> Unit): NuvioImportResult {
suspend fun sync(
profile: UserProfile,
onStep: (NuvioImportStep) -> Unit,
onItemProgress: (index: Int, total: Int, title: String) -> Unit = { _, _, _ -> }
): NuvioImportResult {
val refreshedProfile = refreshProfileIfNeeded(profile)
val accessToken = refreshedProfile.nuvioAccessToken?.takeIf { it.isNotBlank() }
?: throw IllegalStateException("Nuvio is not connected")
@ -72,7 +76,7 @@ class NuvioAccountImportCoordinator(
com.fluxa.app.data.remote.NuvioUser(userId, refreshedProfile.nuvioEmail ?: refreshedProfile.email)
}
)
return import(refreshedProfile, session, onStep)
return import(refreshedProfile, session, onStep, onItemProgress)
}
private suspend inline fun authenticate(call: suspend () -> retrofit2.Response<NuvioSessionDto>): Result<NuvioSession> {
@ -92,7 +96,8 @@ class NuvioAccountImportCoordinator(
suspend fun import(
baseProfile: UserProfile,
session: NuvioSession,
onStep: (NuvioImportStep) -> Unit
onStep: (NuvioImportStep) -> Unit,
onItemProgress: (index: Int, total: Int, title: String) -> Unit = { _, _, _ -> }
): NuvioImportResult {
val connectedProfile = baseProfile.copy(
email = session.user?.email ?: baseProfile.email,
@ -172,7 +177,7 @@ class NuvioAccountImportCoordinator(
if (watchProgressDtos != null) {
val watchProgressJson = gson.toJsonTree(watchProgressDtos).asJsonArray
val addonMetas = fetchAddonMetaNeeds(watchProgressJson, libraryJson, profile.localAddons)
val addonMetas = fetchAddonMetaNeeds(watchProgressJson, libraryJson, profile.localAddons, onItemProgress)
val mergePlan = NuvioCoreBridge.importMergePlan(
libraryJson,
addonMetas,
@ -288,16 +293,22 @@ class NuvioAccountImportCoordinator(
?: connectedProfile.copy(nuvioProfileIndex = primaryIndex)
}
private suspend fun fetchAddonMetaNeeds(watchProgressJson: JsonArray, libraryJson: JsonArray, localAddons: List<String>?): JsonObject {
private suspend fun fetchAddonMetaNeeds(
watchProgressJson: JsonArray,
libraryJson: JsonArray,
localAddons: List<String>?,
onItemProgress: (index: Int, total: Int, title: String) -> Unit
): JsonObject {
val needs = NuvioCoreBridge.progressMetaNeeds(watchProgressJson, libraryJson)
val addonMetas = JsonObject()
needs.forEach { need ->
needs.forEachIndexed { index, need ->
val obj = need.asJsonObject
val contentId = obj.get("contentId")?.takeUnless { it.isJsonNull }?.asString ?: return@forEach
val contentType = obj.get("contentType")?.takeUnless { it.isJsonNull }?.asString ?: return@forEach
val contentId = obj.get("contentId")?.takeUnless { it.isJsonNull }?.asString ?: return@forEachIndexed
val contentType = obj.get("contentType")?.takeUnless { it.isJsonNull }?.asString ?: return@forEachIndexed
val detail = runCatching {
addonRepository.getAddonMetaDetail(type = contentType, id = contentId, authKey = "", localAddons = localAddons)
}.getOrNull() ?: return@forEach
}.getOrNull() ?: return@forEachIndexed
onItemProgress(index + 1, needs.size(), detail.name)
val metaJson = JsonObject().apply {
addProperty("name", detail.name)
detail.poster?.let { addProperty("poster", it) }

View file

@ -21,6 +21,9 @@ data class AuthUiState(
val isSubmitting: Boolean = false,
val importSteps: Set<AuthImportStep> = emptySet(),
val importDone: Boolean = false,
val importItemIndex: Int? = null,
val importItemTotal: Int? = null,
val importItemTitle: String? = null,
val isAuthenticated: Boolean = false
)

View file

@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
@ -322,20 +323,46 @@ private fun NuvioImportingStage(
Spacer(Modifier.height(20.dp))
IMPORT_STEP_ORDER.forEach { (step, key) ->
val complete = step in state.importSteps
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(vertical = 6.dp)
) {
Box(
modifier = Modifier
.size(18.dp)
.background(
if (complete) Color.White.copy(alpha = 0.9f) else Color.White.copy(alpha = 0.08f),
CircleShape
)
)
Text(AppStrings.t(language, key), color = Color.White.copy(alpha = if (complete) 0.85f else 0.3f))
val active = !complete && !state.importDone && (state.importSteps.size == IMPORT_STEP_ORDER.indexOfFirst { it.first == step })
Column(modifier = Modifier.padding(vertical = 6.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Box(
modifier = Modifier
.size(18.dp)
.background(
if (complete) Color.White.copy(alpha = 0.9f) else Color.White.copy(alpha = 0.08f),
CircleShape
),
contentAlignment = Alignment.Center
) {
if (complete) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = Color.Black,
modifier = Modifier.size(12.dp)
)
}
}
Text(AppStrings.t(language, key), color = Color.White.copy(alpha = if (complete) 0.85f else 0.3f))
}
if (active && state.importItemTitle != null && state.importItemIndex != null && state.importItemTotal != null) {
Text(
AppStrings.format(
language,
"auth.nuvio.import.item_progress",
state.importItemIndex,
state.importItemTotal,
state.importItemTitle
),
color = Color.White.copy(alpha = 0.5f),
fontSize = 12.sp,
modifier = Modifier.padding(start = 28.dp, top = 2.dp)
)
}
}
}
if (state.importDone) {

View file

@ -25,6 +25,7 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -373,20 +374,46 @@ private fun TvNuvioImportingStage(
Spacer(Modifier.height(20.dp))
TV_IMPORT_STEP_ORDER.forEach { (step, key) ->
val complete = step in state.importSteps
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(vertical = 6.dp)
) {
Box(
modifier = Modifier
.size(18.dp)
.background(
if (complete) Color.White.copy(alpha = 0.9f) else Color.White.copy(alpha = 0.08f),
CircleShape
)
)
Text(AppStrings.t(language, key), color = Color.White.copy(alpha = if (complete) 0.85f else 0.3f))
val active = !complete && !state.importDone && (state.importSteps.size == TV_IMPORT_STEP_ORDER.indexOfFirst { it.first == step })
Column(modifier = Modifier.padding(vertical = 6.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Box(
modifier = Modifier
.size(18.dp)
.background(
if (complete) Color.White.copy(alpha = 0.9f) else Color.White.copy(alpha = 0.08f),
CircleShape
),
contentAlignment = Alignment.Center
) {
if (complete) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = Color.Black,
modifier = Modifier.size(12.dp)
)
}
}
Text(AppStrings.t(language, key), color = Color.White.copy(alpha = if (complete) 0.85f else 0.3f))
}
if (active && state.importItemTitle != null && state.importItemIndex != null && state.importItemTotal != null) {
Text(
AppStrings.format(
language,
"auth.nuvio.import.item_progress",
state.importItemIndex,
state.importItemTotal,
state.importItemTitle
),
color = Color.White.copy(alpha = 0.5f),
fontSize = 12.sp,
modifier = Modifier.padding(start = 28.dp, top = 2.dp)
)
}
}
}
if (state.importDone) {