From 5230a00ce4a1273d616fa9261d66aa678ba46945 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Tue, 4 Aug 2026 21:40:05 +0530 Subject: [PATCH 1/2] feat(settings): add refresh addons action to sync latest changes (#404) - Add refreshInstalledAddons() in StreamRepository to re-fetch custom addon manifests, clear stream caches, and preserve user preferences. - Add refreshAddons() in SettingsViewModel to pull remote cloud profile state, refresh addons, and resync catalogs. - Mobile UI: Add gesture-based pull-down refresh with stock loader and small italic 'Pulling latest addon' text. - TV UI: Add focusable 'Refresh Addons' button row before 'Add Addon'. Closes #404 --- .../tv/data/repository/StreamRepository.kt | 202 ++++++++++++------ .../tv/ui/screens/settings/SettingsScreen.kt | 122 ++++++++++- .../ui/screens/settings/SettingsViewModel.kt | 36 ++++ app/src/main/res/values/strings.xml | 3 + 4 files changed, 287 insertions(+), 76 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index 35a63284..5b86af1d 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -626,87 +626,95 @@ class StreamRepository @Inject constructor( return true } - /** - * Add a custom Stremio addon from URL - - * Fetches manifest and stores addon info - */ - suspend fun addCustomAddon(url: String, customName: String? = null): Result = withContext(Dispatchers.IO) { - try { - val normalizedUrl = resolveAddonInstallUrl(url) - if (normalizedUrl.isBlank()) { - return@withContext Result.failure(IllegalArgumentException(context.getString(R.string.addon_error_url_empty))) - } + private suspend fun hydrateCustomAddon(url: String, customName: String? = null): Addon { + val normalizedUrl = resolveAddonInstallUrl(url) + if (normalizedUrl.isBlank()) { + throw IllegalArgumentException(context.getString(R.string.addon_error_url_empty)) + } - httpLocalScraperRuntime.fetchInstallCandidate( + httpLocalScraperRuntime.fetchInstallCandidate( + url = normalizedUrl, + customName = customName + )?.let { candidate -> + val addonId = buildAddonInstanceId(candidate.manifest.id, normalizedUrl) + return Addon( + id = addonId, + name = candidate.name, + version = candidate.version, + description = candidate.description, + isInstalled = true, + isEnabled = true, + type = AddonType.CUSTOM, url = normalizedUrl, - customName = customName - )?.let { httpCandidate -> - return@withContext Result.success( - installHttpLocalScraperCandidate( - normalizedUrl = normalizedUrl, - candidate = httpCandidate - ) - ) - } - - val manifestUrl = getManifestUrl(normalizedUrl) + logo = candidate.logo, + manifest = candidate.manifest, + transportUrl = candidate.transportUrl + ) + } - val manifest = try { - streamApi.getAddonManifest(manifestUrl) - } catch (manifestError: Exception) { - val httpCandidate = httpLocalScraperRuntime.fetchInstallCandidate( - url = normalizedUrl, - customName = customName - ) ?: throw manifestError - return@withContext Result.success( - installHttpLocalScraperCandidate( - normalizedUrl = normalizedUrl, - candidate = httpCandidate - ) - ) - } + val manifestUrl = getManifestUrl(normalizedUrl) - val transportUrl = getTransportUrl(normalizedUrl) - val addonManifest = convertToAddonManifest(manifest) - val resolvedName = customName?.trim()?.takeIf { it.isNotBlank() } ?: manifest.name - val addonId = buildAddonInstanceId(manifest.id, normalizedUrl) - - // Classify the addon based on the resources its manifest declares. - // - If it declares `subtitles` but NOT `stream`, it's a pure subtitle addon - // (e.g. Wizdom, Ktuvit) and gets AddonType.SUBTITLE so the subtitle fetcher - // picks it up and the stream resolver correctly ignores it. - // - If it declares `stream` (with or without subtitles), keep it as CUSTOM so - // the stream resolver queries it. The subtitle fetcher has been updated - // separately to also include CUSTOM addons whose manifest declares a - // subtitles resource, so hybrid addons still get queried for both. - // - Everything else stays CUSTOM, matching the previous default. - // Fixes issue #80 where Wizdom/Ktuvit were installed but never queried because - // every user-added addon was hardcoded to CUSTOM regardless of its manifest. - val resourceNames = addonManifest.resources.map { it.name }.toSet() - val hasSubtitles = "subtitles" in resourceNames - val hasStream = "stream" in resourceNames - val addonType = when { - hasSubtitles && !hasStream -> AddonType.SUBTITLE - else -> AddonType.CUSTOM - } - - val newAddon = Addon( + val manifest = try { + streamApi.getAddonManifest(manifestUrl) + } catch (manifestError: Exception) { + val candidate = httpLocalScraperRuntime.fetchInstallCandidate( + url = normalizedUrl, + customName = customName + ) ?: throw manifestError + val addonId = buildAddonInstanceId(candidate.manifest.id, normalizedUrl) + return Addon( id = addonId, - name = resolvedName, - version = manifest.version, - description = manifest.description ?: "", + name = candidate.name, + version = candidate.version, + description = candidate.description, isInstalled = true, isEnabled = true, - type = addonType, + type = AddonType.CUSTOM, url = normalizedUrl, - logo = manifest.logo, - manifest = addonManifest, - transportUrl = transportUrl + logo = candidate.logo, + manifest = candidate.manifest, + transportUrl = candidate.transportUrl ) + } + + val transportUrl = getTransportUrl(normalizedUrl) + val addonManifest = convertToAddonManifest(manifest) + val resolvedName = customName?.trim()?.takeIf { it.isNotBlank() } ?: manifest.name + val addonId = buildAddonInstanceId(manifest.id, normalizedUrl) + + val resourceNames = addonManifest.resources.map { it.name }.toSet() + val hasSubtitles = "subtitles" in resourceNames + val hasStream = "stream" in resourceNames + val addonType = when { + hasSubtitles && !hasStream -> AddonType.SUBTITLE + else -> AddonType.CUSTOM + } + + return Addon( + id = addonId, + name = resolvedName, + version = manifest.version, + description = manifest.description ?: "", + isInstalled = true, + isEnabled = true, + type = addonType, + url = normalizedUrl, + logo = manifest.logo, + manifest = addonManifest, + transportUrl = transportUrl + ) + } + /** + * Add a custom Stremio addon from URL - + * Fetches manifest and stores addon info + */ + suspend fun addCustomAddon(url: String, customName: String? = null): Result = withContext(Dispatchers.IO) { + try { + val newAddon = hydrateCustomAddon(url, customName) val addons = installedAddons.first().toMutableList() // Remove existing addon with same ID if present - addons.removeAll { it.id == addonId } + addons.removeAll { it.id == newAddon.id } addons.add(newAddon) saveAddons(addons) @@ -718,6 +726,57 @@ class StreamRepository @Inject constructor( } } + /** + * Refresh all installed custom addons by re-fetching manifests, invalidating stream caches, + * and returning a refresh report. + */ + suspend fun refreshInstalledAddons(): AddonRefreshReport = withContext(Dispatchers.IO) { + val currentAddons = installedAddons.first() + var refreshedCount = 0 + var failedCount = 0 + + val updatedAddons = currentAddons.map { oldAddon -> + val addonUrl = oldAddon.url?.takeIf { it.isNotBlank() } + if (addonUrl == null) { + oldAddon + } else { + try { + val hydrated = hydrateCustomAddon(url = addonUrl, customName = oldAddon.name) + refreshedCount++ + hydrated.copy( + id = oldAddon.id, + isEnabled = oldAddon.isEnabled, + isInstalled = oldAddon.isInstalled + ) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Log.w(TAG, "[AddonRefresh] Failed to refresh addon ${oldAddon.id} from $addonUrl", e) + failedCount++ + oldAddon + } + } + } + + saveAddons(updatedAddons) + + synchronized(streamResultCache) { streamResultCache.clear() } + resolvedStreamCache.clear() + synchronized(streamAddonsCache) { + streamAddonsCache.clear() + cachedStreamAddonsFingerprint = null + } + + runCatching { + val activeProfileId = profileManager.getProfileIdSync() + val bundleKey = streamResultCacheBundleKey(activeProfileId) + context.streamDataStore.edit { prefs -> + prefs.remove(bundleKey) + } + } + + AddonRefreshReport(refreshed = refreshedCount, failed = failedCount) + } + private suspend fun installHttpLocalScraperCandidate( normalizedUrl: String, candidate: HttpLocalScraperInstallCandidate @@ -4022,3 +4081,8 @@ data class ProgressiveStreamResult( val totalAddons: Int, val isFinal: Boolean ) + +data class AddonRefreshReport( + val refreshed: Int = 0, + val failed: Int = 0 +) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 6e619793..841358b3 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -92,6 +92,14 @@ import androidx.compose.material.icons.filled.Unarchive import androidx.compose.material.icons.filled.Archive import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.material3.CircularProgressIndicator import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.Surface import androidx.compose.foundation.layout.PaddingValues @@ -108,6 +116,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.compositionLocalOf import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateMapOf @@ -451,7 +460,7 @@ fun SettingsScreen( } "home_server" -> uiState.homeServerConnections.size + 3 "catalogs" -> uiState.catalogs.size + 1 // Add + Import + catalogs - "stremio" -> stremioAddons.size // rows + add button + "stremio" -> stremioAddons.size + 1 // rows + refresh + add button "plugins" -> pluginsMaxIndex "accounts" -> 6 // Cloud + Trakt + Telegram + Force Sync + App Update + Privacy/Data + MDBList else -> 0 @@ -1109,6 +1118,9 @@ fun SettingsScreen( else -> viewModel.toggleAddon(addon.id) } } + contentFocusIndex == stremioAddons.size -> { + viewModel.refreshAddons() + } else -> { showCustomAddonInput = true } @@ -1621,13 +1633,15 @@ fun SettingsScreen( ) "stremio" -> StremioAddonsSettings( addons = stremioAddons, + isRefreshingAddons = uiState.isRefreshingAddons, focusedIndex = if (activeZone == Zone.CONTENT) contentFocusIndex else -1, focusedActionIndex = addonActionIndex, onToggleAddon = { viewModel.toggleAddon(it) }, onMoveAddonUp = { viewModel.moveAddonUp(it) }, onMoveAddonDown = { viewModel.moveAddonDown(it) }, onDeleteAddon = { viewModel.removeAddon(it) }, - onAddCustomAddon = { showCustomAddonInput = true } + onAddCustomAddon = { showCustomAddonInput = true }, + onRefreshAddons = { viewModel.refreshAddons() } ) "plugins" -> { com.arflix.tv.ui.screens.plugin.PluginScreen( @@ -4092,13 +4106,15 @@ private fun MobileSettingsSubPage( "Addons" -> { StremioAddonsSettings( addons = stremioAddons, + isRefreshingAddons = uiState.isRefreshingAddons, focusedIndex = -1, focusedActionIndex = 0, onToggleAddon = { viewModel.toggleAddon(it) }, onMoveAddonUp = { viewModel.moveAddonUp(it) }, onMoveAddonDown = { viewModel.moveAddonDown(it) }, onDeleteAddon = { viewModel.removeAddon(it) }, - onAddCustomAddon = onAddCustomAddonClick + onAddCustomAddon = onAddCustomAddonClick, + onRefreshAddons = { viewModel.refreshAddons() } ) } "Plugins & Extensions" -> { @@ -7543,20 +7559,86 @@ private fun CatalogActionChip( @Composable private fun StremioAddonsSettings( addons: List = emptyList(), + isRefreshingAddons: Boolean = false, focusedIndex: Int = -1, focusedActionIndex: Int = 0, onToggleAddon: (String) -> Unit = {}, onMoveAddonUp: (String) -> Unit = {}, onMoveAddonDown: (String) -> Unit = {}, onDeleteAddon: (String) -> Unit = {}, - onAddCustomAddon: () -> Unit = {} + onAddCustomAddon: () -> Unit = {}, + onRefreshAddons: () -> Unit = {} ) { val isMobile = LocalDeviceType.current.isTouchDevice() if (isMobile) { - Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { + var pullOffsetY by remember { mutableFloatStateOf(0f) } + val density = LocalDensity.current + val refreshThreshold = remember(density) { with(density) { 90.dp.toPx() } } + + Column( + modifier = Modifier + .fillMaxWidth() + .pointerInput(isRefreshingAddons) { + detectVerticalDragGestures( + onVerticalDrag = { change, dragAmount -> + if (dragAmount > 0 || pullOffsetY > 0f) { + change.consume() + pullOffsetY = (pullOffsetY + dragAmount * 0.5f).coerceAtLeast(0f) + } + }, + onDragEnd = { + if (pullOffsetY >= refreshThreshold && !isRefreshingAddons) { + onRefreshAddons() + } + pullOffsetY = 0f + }, + onDragCancel = { + pullOffsetY = 0f + } + ) + }, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + AnimatedVisibility( + visible = isRefreshingAddons || pullOffsetY > 0f, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Pink, + strokeWidth = 2.5.dp + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = stringResource(R.string.settings_pulling_latest_addon), + style = ArflixTypography.caption.copy( + fontSize = 12.sp, + fontStyle = FontStyle.Italic + ), + color = TextSecondary + ) + } + } + MobileSettingsCategory(title = stringResource(R.string.settings_section_add_addon)) { - MobileSettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.add_addon), subtitle = stringResource(R.string.settings_install_custom_addon), value = "", isFocused = false, showDivider = false, onClick = onAddCustomAddon) + MobileSettingsRow( + icon = Icons.Default.Add, + title = stringResource(R.string.add_addon), + subtitle = stringResource(R.string.settings_install_custom_addon), + value = "", + isFocused = false, + showDivider = false, + onClick = onAddCustomAddon + ) } MobileSettingsCategory(title = stringResource(R.string.settings_section_my_addons)) { if (addons.isEmpty()) { @@ -7649,7 +7731,33 @@ private fun StremioAddonsSettings( } } Spacer(modifier = Modifier.height(24.dp)) - Row(modifier = Modifier.settingsFocusSlot(addons.size).fillMaxWidth().clickable(onClick = onAddCustomAddon).background(if (focusedIndex == addons.size) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), RoundedCornerShape(12.dp)).border(width = if (focusedIndex == addons.size) 2.dp else 0.dp, color = if (focusedIndex == addons.size) Pink else Color.Transparent, shape = RoundedCornerShape(12.dp)).padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { + Row( + modifier = Modifier + .settingsFocusSlot(addons.size) + .fillMaxWidth() + .clickable(onClick = onRefreshAddons) + .background(if (focusedIndex == addons.size) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), RoundedCornerShape(12.dp)) + .border(width = if (focusedIndex == addons.size) 2.dp else 0.dp, color = if (focusedIndex == addons.size) Pink else Color.Transparent, shape = RoundedCornerShape(12.dp)) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon(Icons.Default.Refresh, contentDescription = null, tint = Pink, modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(12.dp)) + Text(stringResource(R.string.refresh_addons), style = ArflixTypography.button, color = Pink) + } + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier + .settingsFocusSlot(addons.size + 1) + .fillMaxWidth() + .clickable(onClick = onAddCustomAddon) + .background(if (focusedIndex == addons.size + 1) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), RoundedCornerShape(12.dp)) + .border(width = if (focusedIndex == addons.size + 1) 2.dp else 0.dp, color = if (focusedIndex == addons.size + 1) Pink else Color.Transparent, shape = RoundedCornerShape(12.dp)) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { Icon(Icons.Default.Widgets, contentDescription = null, tint = Pink, modifier = Modifier.size(20.dp)) Spacer(modifier = Modifier.width(12.dp)) Text(stringResource(R.string.add_addon), style = ArflixTypography.button, color = Pink) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 69386dbe..88eaf1bb 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -179,6 +179,7 @@ data class SettingsUiState( val packError: String? = null, // Addons val addons: List = emptyList(), + val isRefreshingAddons: Boolean = false, val torrServerBaseUrl: String = "", val homeServerConnection: HomeServerConnection? = null, val homeServerConnections: List = emptyList(), @@ -1677,6 +1678,41 @@ class SettingsViewModel @Inject constructor( } } + fun refreshAddons() { + if (_uiState.value.isRefreshingAddons) return + _uiState.value = _uiState.value.copy(isRefreshingAddons = true) + viewModelScope.launch { + try { + if (authRepository.hasValidCloudSyncSession()) { + restoreCloudStateToLocalInternal( + silent = true, + pushPendingLocalFirst = false + ) + } + val report = streamRepository.refreshInstalledAddons() + val updatedAddons = streamRepository.installedAddons.first() + runCatching { + catalogRepository.syncAddonCatalogs(updatedAddons) + } + val toast = "${report.refreshed} addons refreshed, ${report.failed} failed" + _uiState.value = _uiState.value.copy( + addons = updatedAddons, + isRefreshingAddons = false, + toastMessage = toast, + toastType = if (report.failed == 0) ToastType.SUCCESS else ToastType.INFO + ) + syncLocalStateToCloud(silent = true) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + _uiState.value = _uiState.value.copy( + isRefreshingAddons = false, + toastMessage = "Failed to refresh addons", + toastType = ToastType.ERROR + ) + } + } + } + private fun observeAuthState() { viewModelScope.launch { authRepository.authState.collect { state -> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fe7f3d0a..4e623603 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -597,6 +597,9 @@ From %1$s From Home Server Custom catalog + Refresh Addons + Pull latest addon changes for current profile + Pulling latest addons ADD ADDON Install a custom Stremio addon by URL MY ADDONS From 3f820ed80ac0c5d7afd98d121b11db8a0dd065bd Mon Sep 17 00:00:00 2001 From: Arvin Date: Sun, 9 Aug 2026 12:29:22 +0200 Subject: [PATCH 2/2] fix(settings): harden addon refresh flow --- .../tv/data/repository/StreamRepository.kt | 9 +- .../tv/ui/screens/settings/SettingsScreen.kt | 92 ++++++------------- .../ui/screens/settings/SettingsViewModel.kt | 10 +- 3 files changed, 43 insertions(+), 68 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index 2116fe4c..1ca3b8ef 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -808,7 +808,10 @@ class StreamRepository @Inject constructor( var failedCount = 0 val updatedAddons = currentAddons.map { oldAddon -> - val addonUrl = oldAddon.url?.takeIf { it.isNotBlank() } + val isRefreshable = oldAddon.isInstalled && + oldAddon.runtimeKind == RuntimeKind.STREMIO && + defaultAddons.none { it.id == oldAddon.id } + val addonUrl = oldAddon.url?.takeIf { isRefreshable && it.isNotBlank() } if (addonUrl == null) { oldAddon } else { @@ -818,7 +821,9 @@ class StreamRepository @Inject constructor( hydrated.copy( id = oldAddon.id, isEnabled = oldAddon.isEnabled, - isInstalled = oldAddon.isInstalled + isInstalled = oldAddon.isInstalled, + runtimeKind = oldAddon.runtimeKind, + installSource = oldAddon.installSource ) } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index e8e3042f..c25a49ff 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -92,14 +92,6 @@ import androidx.compose.material.icons.filled.Unarchive import androidx.compose.material.icons.filled.Archive import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically -import androidx.compose.material3.CircularProgressIndicator import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.Surface import androidx.compose.foundation.layout.PaddingValues @@ -118,7 +110,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.compositionLocalOf import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateMapOf @@ -7459,64 +7450,25 @@ private fun StremioAddonsSettings( val isMobile = LocalDeviceType.current.isTouchDevice() if (isMobile) { - var pullOffsetY by remember { mutableFloatStateOf(0f) } - val density = LocalDensity.current - val refreshThreshold = remember(density) { with(density) { 90.dp.toPx() } } - Column( - modifier = Modifier - .fillMaxWidth() - .pointerInput(isRefreshingAddons) { - detectVerticalDragGestures( - onVerticalDrag = { change, dragAmount -> - if (dragAmount > 0 || pullOffsetY > 0f) { - change.consume() - pullOffsetY = (pullOffsetY + dragAmount * 0.5f).coerceAtLeast(0f) - } - }, - onDragEnd = { - if (pullOffsetY >= refreshThreshold && !isRefreshingAddons) { - onRefreshAddons() - } - pullOffsetY = 0f - }, - onDragCancel = { - pullOffsetY = 0f - } - ) - }, + modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(24.dp) ) { - AnimatedVisibility( - visible = isRefreshingAddons || pullOffsetY > 0f, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 14.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = Pink, - strokeWidth = 2.5.dp - ) - Spacer(modifier = Modifier.height(6.dp)) - Text( - text = stringResource(R.string.settings_pulling_latest_addon), - style = ArflixTypography.caption.copy( - fontSize = 12.sp, - fontStyle = FontStyle.Italic - ), - color = TextSecondary - ) - } - } - MobileSettingsCategory(title = stringResource(R.string.settings_section_add_addon)) { + MobileSettingsRow( + icon = Icons.Default.Refresh, + title = stringResource(R.string.refresh_addons), + subtitle = stringResource(R.string.settings_refresh_addons_desc), + value = if (isRefreshingAddons) { + stringResource(R.string.settings_pulling_latest_addon) + } else { + "" + }, + isFocused = false, + onClick = { + if (!isRefreshingAddons) onRefreshAddons() + } + ) MobileSettingsRow( icon = Icons.Default.Add, title = stringResource(R.string.add_addon), @@ -7622,7 +7574,7 @@ private fun StremioAddonsSettings( modifier = Modifier .settingsFocusSlot(addons.size) .fillMaxWidth() - .clickable(onClick = onRefreshAddons) + .clickable(enabled = !isRefreshingAddons, onClick = onRefreshAddons) .background(if (focusedIndex == addons.size) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), RoundedCornerShape(12.dp)) .border(width = if (focusedIndex == addons.size) 2.dp else 0.dp, color = if (focusedIndex == addons.size) Pink else Color.Transparent, shape = RoundedCornerShape(12.dp)) .padding(horizontal = 16.dp, vertical = 14.dp), @@ -7631,7 +7583,17 @@ private fun StremioAddonsSettings( ) { Icon(Icons.Default.Refresh, contentDescription = null, tint = Pink, modifier = Modifier.size(20.dp)) Spacer(modifier = Modifier.width(12.dp)) - Text(stringResource(R.string.refresh_addons), style = ArflixTypography.button, color = Pink) + Text( + text = stringResource( + if (isRefreshingAddons) { + R.string.settings_pulling_latest_addon + } else { + R.string.refresh_addons + } + ), + style = ArflixTypography.button, + color = Pink + ) } Spacer(modifier = Modifier.height(12.dp)) Row( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 8675cf65..cbac656b 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -1698,10 +1698,18 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { try { if (authRepository.hasValidCloudSyncSession()) { - restoreCloudStateToLocalInternal( + val restoreResult = restoreCloudStateToLocalInternal( silent = true, pushPendingLocalFirst = false ) + if (restoreResult == CloudRestoreResult.FAILED) { + _uiState.value = _uiState.value.copy( + isRefreshingAddons = false, + toastMessage = "Cloud restore failed; addons were not changed", + toastType = ToastType.ERROR + ) + return@launch + } } val report = streamRepository.refreshInstalledAddons() val updatedAddons = streamRepository.installedAddons.first()