From 6aa105e2c4ab36f15c5decadc3c5cdaf7f25c04f Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Fri, 3 Jul 2026 18:47:53 +0530 Subject: [PATCH 1/9] feat: implement catalog pack support to import multiple catalogs via manifest URLs --- app/src/main/AndroidManifest.xml | 19 + .../main/kotlin/com/arflix/tv/MainActivity.kt | 33 ++ .../com/arflix/tv/data/model/CatalogModels.kt | 19 +- .../tv/data/repository/CatalogRepository.kt | 77 +++ .../tv/data/repository/TraktRepository.kt | 2 +- .../com/arflix/tv/navigation/AppNavigation.kt | 9 +- .../tv/ui/screens/settings/SettingsScreen.kt | 553 +++++++++++++++++- .../ui/screens/settings/SettingsViewModel.kt | 82 +++ 8 files changed, 779 insertions(+), 15 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4f2fb864c..30c5751d0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -67,6 +67,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt index 16e4b3f09..d21822ded 100644 --- a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt +++ b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt @@ -166,6 +166,7 @@ class MainActivity : ComponentActivity() { private var jankStats: JankStats? = null private var pendingLauncherRequest by mutableStateOf(null) + private var pendingInstallPackUrl by mutableStateOf(null) // StartupViewModel for parallel loading during splash private val startupViewModel: StartupViewModel by viewModels() @@ -204,6 +205,7 @@ class MainActivity : ComponentActivity() { @Suppress("DEPRECATION") overridePendingTransition(0, 0) pendingLauncherRequest = parseLauncherRequest(intent) + pendingInstallPackUrl = parseInstallPackUrl(intent) val crashPrefs = getSharedPreferences("arvio_crash_store", Context.MODE_PRIVATE) if (crashPrefs.getBoolean("has_pending_crash_report", false)) { @@ -356,6 +358,8 @@ class MainActivity : ComponentActivity() { skipProfileSelection = skipProfileSelection, pendingLauncherRequest = pendingLauncherRequest, onConsumeLauncherRequest = { pendingLauncherRequest = null }, + pendingInstallPackUrl = pendingInstallPackUrl, + onConsumeInstallPackUrl = { pendingInstallPackUrl = null }, preloadedCategories = startupState.categories, preloadedHeroItem = startupState.heroItem, preloadedHeroLogoUrl = startupState.heroLogoUrl, @@ -394,6 +398,7 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) pendingLauncherRequest = parseLauncherRequest(intent) + pendingInstallPackUrl = parseInstallPackUrl(intent) } override fun onWindowFocusChanged(hasFocus: Boolean) { @@ -421,6 +426,19 @@ private fun MainActivity.parseLauncherRequest(intent: android.content.Intent?): return intent?.data?.toLauncherContinueWatchingRequest() } +private fun MainActivity.parseInstallPackUrl(intent: android.content.Intent?): String? { + val data = intent?.data ?: return null + val scheme = data.scheme ?: return null + val host = data.host ?: return null + return if (scheme == "arvio" && host == "install-pack") { + data.getQueryParameter("url") + } else if ((scheme == "http" || scheme == "https") && host == "arvio.app" && data.path?.startsWith("/install-pack") == true) { + data.getQueryParameter("url") + } else { + null + } +} + private fun ComponentActivity.runAfterFirstDraw(block: () -> Unit) { val content = window.decorView content.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { @@ -541,6 +559,8 @@ fun ArflixApp( skipProfileSelection: Boolean? = null, pendingLauncherRequest: LauncherContinueWatchingRequest? = null, onConsumeLauncherRequest: () -> Unit = {}, + pendingInstallPackUrl: String? = null, + onConsumeInstallPackUrl: () -> Unit = {}, preloadedCategories: List = emptyList(), preloadedHeroItem: com.arflix.tv.data.model.MediaItem? = null, preloadedHeroLogoUrl: String? = null, @@ -690,6 +710,19 @@ fun ArflixApp( } onConsumeLauncherRequest() } + + LaunchedEffect(activeProfile?.id, pendingInstallPackUrl) { + val packUrl = pendingInstallPackUrl ?: return@LaunchedEffect + if (activeProfile == null) return@LaunchedEffect + + val encodedUrl = java.net.URLEncoder.encode(packUrl, "UTF-8") + val route = "settings?initialSection=catalogs&installPackUrl=$encodedUrl" + navController.navigate(route) { + popUpTo(Screen.ProfileSelection.route) { inclusive = true } + launchSingleTop = true + } + onConsumeInstallPackUrl() + } } private fun enqueueFullTraktSync(context: android.content.Context) { diff --git a/app/src/main/kotlin/com/arflix/tv/data/model/CatalogModels.kt b/app/src/main/kotlin/com/arflix/tv/data/model/CatalogModels.kt index 1b3c71220..46ad73b77 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/model/CatalogModels.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/model/CatalogModels.kt @@ -100,7 +100,9 @@ data class CatalogConfig( val collectionTileShape: CollectionTileShape = CollectionTileShape.LANDSCAPE, val collectionHideTitle: Boolean = false, val collectionSources: List = emptyList(), - val requiredAddonUrls: List = emptyList() + val requiredAddonUrls: List = emptyList(), + val packId: String? = null, + val packName: String? = null ) : Serializable data class CatalogDiscoveryResult( @@ -123,3 +125,18 @@ data class CatalogValidationResult( val sourceType: CatalogSourceType? = null, val error: String? = null ) + +data class CatalogPackManifest( + val id: String, + val name: String, + val author: String? = null, + val version: String? = null, + val description: String? = null, + val catalogs: List +) : Serializable + +data class CatalogPackItem( + val name: String, + val url: String +) : Serializable + diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt index 83fa1a094..6c7b95709 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt @@ -10,6 +10,8 @@ import com.arflix.tv.data.model.AddonCatalog import com.arflix.tv.data.model.AddonType import com.arflix.tv.data.model.CatalogConfig import com.arflix.tv.data.model.CatalogKind +import com.arflix.tv.data.model.CatalogPackManifest +import com.arflix.tv.data.model.CatalogPackItem import com.arflix.tv.data.model.CollectionGroupKind import com.arflix.tv.data.model.CollectionSourceConfig import com.arflix.tv.data.model.CollectionTileShape @@ -706,6 +708,81 @@ class CatalogRepository @Inject constructor( return true } + suspend fun fetchCatalogPackManifest(packUrl: String): Result { + val json = fetchUrl(packUrl) + ?: return Result.failure(IllegalArgumentException("Failed to fetch catalog pack from URL")) + val manifest = try { + val type = object : com.google.gson.reflect.TypeToken() {}.type + gson.fromJson(json, type) + } catch (e: Exception) { + null + } ?: return Result.failure(IllegalArgumentException("Failed to parse catalog pack manifest")) + + if (manifest.id.isBlank() || manifest.name.isBlank()) { + return Result.failure(IllegalArgumentException("Invalid catalog pack manifest: missing ID or Name")) + } + return Result.success(manifest) + } + + suspend fun addCatalogPack(packUrl: String): Result { + val manifestResult = fetchCatalogPackManifest(packUrl) + if (manifestResult.isFailure) return manifestResult + val manifest = manifestResult.getOrThrow() + + val current = getCatalogs().toMutableList() + val addedConfigs = mutableListOf() + + for (item in manifest.catalogs) { + val validation = validateCatalogUrl(item.url) + if (!validation.isValid || validation.normalizedUrl == null || validation.sourceType == null) { + continue + } + val normalizedUrl = validation.normalizedUrl + val sourceType = validation.sourceType + + // Skip if this URL is already added + if (current.any { it.sourceUrl.equals(normalizedUrl, ignoreCase = true) }) { + continue + } + + val resolved = resolveMetadata(normalizedUrl, sourceType) + ?: fallbackMetadata(normalizedUrl, sourceType) + ?: continue + + val stableId = "custom_${sha256Short(manifest.id + "|" + normalizedUrl)}" + val newCatalog = CatalogConfig( + id = stableId, + title = item.name.trim().ifBlank { resolved.title }, + sourceType = sourceType, + sourceUrl = normalizedUrl, + sourceRef = resolved.sourceRef, + isPreinstalled = false, + packId = manifest.id, + packName = manifest.name + ) + addedConfigs.add(newCatalog) + } + + if (addedConfigs.isEmpty()) { + return Result.failure(IllegalArgumentException("No new or valid catalogs found in this pack to import")) + } + + current.addAll(0, addedConfigs) + saveCatalogs(current) + return Result.success(manifest) + } + + suspend fun removeCatalogPack(packId: String): Result { + val current = getCatalogs().toMutableList() + val beforeSize = current.size + current.removeAll { it.packId == packId } + if (current.size == beforeSize) { + return Result.failure(IllegalArgumentException("No catalogs found for pack: $packId")) + } + saveCatalogs(current) + return Result.success(Unit) + } + suspend fun addCustomCatalog(rawUrl: String): Result { val validation = validateCatalogUrl(rawUrl) if (!validation.isValid || validation.normalizedUrl == null || validation.sourceType == null) { diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt index 71c2b0f28..182de9441 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt @@ -3963,7 +3963,7 @@ data class ContinueWatchingItem( val subtitle = if (mediaType == MediaType.TV && season != null && episode != null) { val base = context?.getString(R.string.continue_season_episode, season, episode) - ?: "Continue S${season}E${episode}" + ?: "Continue S${season}.E${episode}" if (!resumeLabel.isNullOrBlank()) { context?.getString(R.string.continue_from, resumeLabel) ?: "$base from $resumeLabel" } else { diff --git a/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt index e45972a73..29c293014 100644 --- a/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt +++ b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt @@ -290,7 +290,7 @@ fun AppNavigation( // Settings screen composable( - route = "settings?autoCloudAuth={autoCloudAuth}&initialSection={initialSection}", + route = "settings?autoCloudAuth={autoCloudAuth}&initialSection={initialSection}&installPackUrl={installPackUrl}", arguments = listOf( navArgument("autoCloudAuth") { type = NavType.BoolType @@ -300,15 +300,22 @@ fun AppNavigation( type = NavType.StringType nullable = true defaultValue = null + }, + navArgument("installPackUrl") { + type = NavType.StringType + nullable = true + defaultValue = null } ) ) { backStackEntry -> val autoCloudAuth = backStackEntry.arguments?.getBoolean("autoCloudAuth") ?: false val initialSection = backStackEntry.arguments?.getString("initialSection") + val installPackUrl = backStackEntry.arguments?.getString("installPackUrl") SettingsScreen( currentProfile = currentProfile, autoStartCloudAuth = autoCloudAuth, initialSection = initialSection, + installPackUrl = installPackUrl, onNavigateToHome = { navigateHome() }, onNavigateToSearch = { navigateTopLevel(Screen.Search.route) }, onNavigateToTv = { navigateTopLevel(Screen.Tv.createRoute()) }, 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 20e2642e3..5784726eb 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 @@ -153,6 +153,7 @@ import androidx.tv.material3.Text import com.arflix.tv.data.model.CatalogConfig import com.arflix.tv.data.model.CatalogDiscoveryResult import com.arflix.tv.data.model.CatalogKind +import com.arflix.tv.data.model.CatalogPackManifest import com.arflix.tv.data.model.CatalogSourceType import com.arflix.tv.data.model.QualityFilterConfig import com.arflix.tv.data.model.RuntimeKind @@ -293,6 +294,7 @@ fun SettingsScreen( currentProfile: com.arflix.tv.data.model.Profile? = null, autoStartCloudAuth: Boolean = false, initialSection: String? = null, + installPackUrl: String? = null, onNavigateToHome: () -> Unit = {}, onNavigateToSearch: () -> Unit = {}, onNavigateToTv: () -> Unit = {}, @@ -369,6 +371,19 @@ fun SettingsScreen( var renameCatalogId by remember { mutableStateOf("") } var renameCatalogTitle by remember { mutableStateOf("") } + // Catalog Pack states + var showCatalogPackInput by remember { mutableStateOf(false) } + var catalogPackInputUrl by remember { mutableStateOf("") } + var showDeletePackConfirm by remember { mutableStateOf(false) } + var deletePackId by remember { mutableStateOf("") } + var deletePackName by remember { mutableStateOf("") } + + LaunchedEffect(installPackUrl) { + if (!installPackUrl.isNullOrBlank()) { + viewModel.loadPackManifest(installPackUrl) + } + } + // Input modal states var showCustomAddonInput by remember { mutableStateOf(false) } var customAddonUrl by remember { mutableStateOf("") } @@ -427,7 +442,7 @@ fun SettingsScreen( 2 + uiState.iptvPlaylists.size // Add + rows + refresh + clear } "home_server" -> uiState.homeServerConnections.size + 3 - "catalogs" -> uiState.catalogs.size // Add + rows + "catalogs" -> uiState.catalogs.size + 1 // Add + Import + catalogs "stremio" -> stremioAddons.size // rows + add button "accounts" -> 5 // Cloud + Trakt + Telegram + Force Sync + App Update + Privacy/Data else -> 0 @@ -676,7 +691,12 @@ fun SettingsScreen( uiState.showAppUpdateDialog || uiState.showUnknownSourcesDialog || showCloudDisconnectConfirm || - showTraktDisconnectConfirm + showTraktDisconnectConfirm || + showCatalogPackInput || + showDeletePackConfirm || + uiState.isPackLoading || + uiState.packError != null || + uiState.pendingPackManifest != null Box( modifier = Modifier @@ -746,7 +766,7 @@ fun SettingsScreen( ) ) { iptvActionIndex-- - } else if (currentSection == "catalogs" && contentFocusIndex > 0 && catalogActionIndex > 0) { + } else if (currentSection == "catalogs" && contentFocusIndex > 1 && catalogActionIndex > 0) { catalogActionIndex-- } else { activeZone = Zone.SECTION @@ -789,7 +809,7 @@ fun SettingsScreen( iptvActionIndex++ } else if (currentSection == "iptv" && !showIptvCategoriesSettings && contentFocusIndex in 1..uiState.iptvPlaylists.size && iptvActionIndex < 5) { iptvActionIndex++ - } else if (currentSection == "catalogs" && contentFocusIndex > 0 && catalogActionIndex < 4) { + } else if (currentSection == "catalogs" && contentFocusIndex > 1 && catalogActionIndex < 4) { catalogActionIndex++ } } @@ -1021,8 +1041,10 @@ fun SettingsScreen( "catalogs" -> { if (contentFocusIndex == 0) { showCatalogInput = true + } else if (contentFocusIndex == 1) { + showCatalogPackInput = true } else { - val catalog = uiState.catalogs.getOrNull(contentFocusIndex - 1) + val catalog = uiState.catalogs.getOrNull(contentFocusIndex - 2) if (catalog != null) { when (catalogActionIndex) { 0 -> { @@ -1037,7 +1059,15 @@ fun SettingsScreen( toggleCatalogueRowLayoutMode(context, catalogueLayoutRowKey(catalog)) } } - else -> viewModel.removeCatalog(catalog.id) + else -> { + if (catalog.packId != null) { + deletePackId = catalog.packId + deletePackName = catalog.packName ?: "" + showDeletePackConfirm = true + } else { + viewModel.removeCatalog(catalog.id) + } + } } } } @@ -1128,11 +1158,21 @@ fun SettingsScreen( onAddIptvClick = { editingIptvIndex = -1; showIptvInput = true }, onEditIptvClick = { idx -> editingIptvIndex = idx; showIptvInput = true }, onAddCatalogClick = { showCatalogInput = true }, + onImportCatalogPackClick = { showCatalogPackInput = true }, onRenameCatalogClick = { catalog -> renameCatalogId = catalog.id renameCatalogTitle = catalog.title showCatalogRename = true }, + onDeleteCatalogClick = { catalog -> + if (catalog.packId != null) { + deletePackId = catalog.packId + deletePackName = catalog.packName ?: "" + showDeletePackConfirm = true + } else { + viewModel.removeCatalog(catalog.id) + } + }, onConnectHomeServerClick = { val connection = uiState.homeServerConnection homeServerUrl = connection?.serverUrl.orEmpty() @@ -1532,6 +1572,7 @@ fun SettingsScreen( focusedIndex = if (activeZone == Zone.CONTENT) contentFocusIndex else -1, focusedActionIndex = catalogActionIndex, onAddCatalog = { showCatalogInput = true }, + onImportCatalogPack = { showCatalogPackInput = true }, onRenameCatalog = { catalog -> renameCatalogId = catalog.id renameCatalogTitle = catalog.title @@ -1539,7 +1580,15 @@ fun SettingsScreen( }, onMoveCatalogUp = { catalog -> viewModel.moveCatalogUp(catalog.id) }, onMoveCatalogDown = { catalog -> viewModel.moveCatalogDown(catalog.id) }, - onDeleteCatalog = { catalog -> viewModel.removeCatalog(catalog.id) } + onDeleteCatalog = { catalog -> + if (catalog.packId != null) { + deletePackId = catalog.packId + deletePackName = catalog.packName ?: "" + showDeletePackConfirm = true + } else { + viewModel.removeCatalog(catalog.id) + } + } ) "stremio" -> StremioAddonsSettings( addons = stremioAddons, @@ -1819,6 +1868,53 @@ fun SettingsScreen( ) } + if (showCatalogPackInput) { + InputModal( + title = "Import Catalog Pack", + fields = listOf( + InputField(label = "Pack URL", value = catalogPackInputUrl, onValueChange = { catalogPackInputUrl = it }) + ), + onConfirm = { + if (catalogPackInputUrl.isNotBlank()) { + viewModel.loadPackManifest(catalogPackInputUrl) + catalogPackInputUrl = "" + showCatalogPackInput = false + } + }, + onDismiss = { + catalogPackInputUrl = "" + showCatalogPackInput = false + } + ) + } + + if (uiState.pendingPackManifest != null || uiState.isPackLoading || uiState.packError != null) { + CatalogPackImportDialog( + pendingPack = uiState.pendingPackManifest, + isLoading = uiState.isPackLoading, + error = uiState.packError, + onConfirm = { manifest -> + viewModel.confirmInstallPack(uiState.pendingPackUrl ?: "") + }, + onDismiss = { + viewModel.clearPendingPack() + } + ) + } + + if (showDeletePackConfirm) { + CatalogPackDeleteConfirmDialog( + packName = deletePackName, + onConfirm = { + viewModel.removeCatalogPack(deletePackId) + showDeletePackConfirm = false + }, + onDismiss = { + showDeletePackConfirm = false + } + ) + } + if (showQualityFiltersModal) { QualityFiltersModal( filters = uiState.qualityFilters, @@ -3277,7 +3373,9 @@ private fun MobileSettingsLayout( onAddIptvClick: () -> Unit, onEditIptvClick: (Int) -> Unit, onAddCatalogClick: () -> Unit, + onImportCatalogPackClick: () -> Unit, onRenameCatalogClick: (CatalogConfig) -> Unit, + onDeleteCatalogClick: (CatalogConfig) -> Unit, onConnectHomeServerClick: () -> Unit, onConnectPlexHomeServerClick: () -> Unit, onAddCustomAddonClick: () -> Unit, @@ -3371,7 +3469,9 @@ private fun MobileSettingsLayout( onAddIptvClick = onAddIptvClick, onEditIptvClick = onEditIptvClick, onAddCatalogClick = onAddCatalogClick, + onImportCatalogPackClick = onImportCatalogPackClick, onRenameCatalogClick = onRenameCatalogClick, + onDeleteCatalogClick = onDeleteCatalogClick, onConnectHomeServerClick = onConnectHomeServerClick, onConnectPlexHomeServerClick = onConnectPlexHomeServerClick, onAddCustomAddonClick = onAddCustomAddonClick, @@ -3581,7 +3681,9 @@ private fun MobileSettingsSubPage( onAddIptvClick: () -> Unit, onEditIptvClick: (Int) -> Unit, onAddCatalogClick: () -> Unit, + onImportCatalogPackClick: () -> Unit, onRenameCatalogClick: (CatalogConfig) -> Unit, + onDeleteCatalogClick: (CatalogConfig) -> Unit, onConnectHomeServerClick: () -> Unit, onConnectPlexHomeServerClick: () -> Unit, onAddCustomAddonClick: () -> Unit, @@ -3914,10 +4016,11 @@ private fun MobileSettingsSubPage( focusedIndex = -1, focusedActionIndex = 0, onAddCatalog = onAddCatalogClick, + onImportCatalogPack = onImportCatalogPackClick, onRenameCatalog = onRenameCatalogClick, onMoveCatalogUp = { viewModel.moveCatalogUp(it.id) }, onMoveCatalogDown = { viewModel.moveCatalogDown(it.id) }, - onDeleteCatalog = { viewModel.removeCatalog(it.id) } + onDeleteCatalog = onDeleteCatalogClick ) } "TV" -> { @@ -7015,6 +7118,7 @@ private fun CatalogsSettings( focusedIndex: Int, focusedActionIndex: Int, onAddCatalog: () -> Unit, + onImportCatalogPack: () -> Unit, onRenameCatalog: (CatalogConfig) -> Unit, onMoveCatalogUp: (CatalogConfig) -> Unit, onMoveCatalogDown: (CatalogConfig) -> Unit, @@ -7042,7 +7146,8 @@ private fun CatalogsSettings( } } MobileSettingsCategory(title = stringResource(R.string.settings_section_add_catalog)) { - MobileSettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.add_catalog), subtitle = stringResource(R.string.add_catalog_desc), value = "", isFocused = false, showDivider = false, onClick = onAddCatalog) + MobileSettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.add_catalog), subtitle = stringResource(R.string.add_catalog_desc), value = "", isFocused = false, showDivider = true, onClick = onAddCatalog) + MobileSettingsRow(icon = Icons.Default.Widgets, title = "Import Catalog Pack", subtitle = "Import a bundle of catalogs from a JSON manifest URL", value = "", isFocused = false, showDivider = false, onClick = onImportCatalogPack) } if (catalogs.isNotEmpty()) { MobileSettingsCategory(title = stringResource(R.string.settings_section_my_catalogs)) { @@ -7050,7 +7155,32 @@ private fun CatalogsSettings( val title = if (catalog.isPreinstalled) { when (catalog.kind) { CatalogKind.COLLECTION -> stringResource(R.string.settings_title_builtin_collection, catalog.title); CatalogKind.COLLECTION_RAIL -> stringResource(R.string.settings_title_builtin_rail, catalog.title); else -> stringResource(R.string.settings_title_builtin, catalog.title) } } else catalog.title val collectionFallback = stringResource(R.string.settings_collection_fallback) val addonFallback = stringResource(R.string.settings_source_addon) - val subtitle = when { catalog.kind == CatalogKind.COLLECTION_RAIL -> { val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback; stringResource(R.string.settings_group_rail, group) }; catalog.kind == CatalogKind.COLLECTION -> { val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback; stringResource(R.string.settings_group_collection, group) }; catalog.sourceType == CatalogSourceType.PREINSTALLED -> stringResource(R.string.settings_preinstalled_catalog); else -> when (catalog.sourceType) { CatalogSourceType.ADDON -> { val addonLabel = catalog.addonName?.takeIf { it.isNotBlank() } ?: addonFallback; stringResource(R.string.settings_from_source, addonLabel) }; CatalogSourceType.HOME_SERVER -> stringResource(R.string.settings_from_home_server); else -> catalog.sourceUrl ?: stringResource(R.string.settings_custom_catalog) } } + val subtitle = when { + catalog.kind == CatalogKind.COLLECTION_RAIL -> { + val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback + stringResource(R.string.settings_group_rail, group) + } + catalog.kind == CatalogKind.COLLECTION -> { + val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback + stringResource(R.string.settings_group_collection, group) + } + catalog.sourceType == CatalogSourceType.PREINSTALLED -> stringResource(R.string.settings_preinstalled_catalog) + else -> when (catalog.sourceType) { + CatalogSourceType.ADDON -> { + val addonLabel = catalog.addonName?.takeIf { it.isNotBlank() } ?: addonFallback + stringResource(R.string.settings_from_source, addonLabel) + } + CatalogSourceType.HOME_SERVER -> stringResource(R.string.settings_from_home_server) + else -> { + val base = catalog.sourceUrl ?: stringResource(R.string.settings_custom_catalog) + if (catalog.packName != null) { + "Pack: ${catalog.packName} • $base" + } else { + base + } + } + } + } val isSelected = selectedIds.contains(catalog.id) val layoutToggleEnabled = catalog.kind != CatalogKind.COLLECTION_RAIL val layoutRowKey = remember(catalog.id, catalog.kind) { catalogueLayoutRowKey(catalog) } @@ -7109,12 +7239,39 @@ private fun CatalogsSettings( Text(text = stringResource(R.string.catalogs), style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.65f), modifier = Modifier.padding(bottom = 20.dp)) SettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.add_catalog), subtitle = stringResource(R.string.add_catalog_desc), value = stringResource(R.string.settings_badge_add), isFocused = focusedIndex == 0, onClick = onAddCatalog, modifier = Modifier.settingsFocusSlot(0)) Spacer(modifier = Modifier.height(16.dp)) + SettingsRow(icon = Icons.Default.Widgets, title = "Import Catalog Pack", subtitle = "Import a bundle of catalogs from a JSON manifest URL", value = "IMPORT", isFocused = focusedIndex == 1, onClick = onImportCatalogPack, modifier = Modifier.settingsFocusSlot(1)) + Spacer(modifier = Modifier.height(16.dp)) catalogs.forEachIndexed { index, catalog -> - val rowFocusIndex = index + 1; val isRowFocused = focusedIndex == rowFocusIndex + val rowFocusIndex = index + 2; val isRowFocused = focusedIndex == rowFocusIndex val title = if (catalog.isPreinstalled) { when (catalog.kind) { CatalogKind.COLLECTION -> stringResource(R.string.settings_title_builtin_collection, catalog.title); CatalogKind.COLLECTION_RAIL -> stringResource(R.string.settings_title_builtin_rail, catalog.title); else -> stringResource(R.string.settings_title_builtin, catalog.title) } } else catalog.title val collectionFallback = stringResource(R.string.settings_collection_fallback) val addonFallback = stringResource(R.string.settings_source_addon) - val subtitle = when { catalog.kind == CatalogKind.COLLECTION_RAIL -> { val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback; stringResource(R.string.settings_group_rail, group) }; catalog.kind == CatalogKind.COLLECTION -> { val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback; stringResource(R.string.settings_group_collection, group) }; catalog.sourceType == CatalogSourceType.PREINSTALLED -> stringResource(R.string.settings_preinstalled_catalog); else -> when (catalog.sourceType) { CatalogSourceType.ADDON -> { val addonLabel = catalog.addonName?.takeIf { it.isNotBlank() } ?: addonFallback; stringResource(R.string.settings_from_source, addonLabel) }; CatalogSourceType.HOME_SERVER -> stringResource(R.string.settings_from_home_server); else -> catalog.sourceUrl ?: stringResource(R.string.settings_custom_catalog) } } + val subtitle = when { + catalog.kind == CatalogKind.COLLECTION_RAIL -> { + val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback + stringResource(R.string.settings_group_rail, group) + } + catalog.kind == CatalogKind.COLLECTION -> { + val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback + stringResource(R.string.settings_group_collection, group) + } + catalog.sourceType == CatalogSourceType.PREINSTALLED -> stringResource(R.string.settings_preinstalled_catalog) + else -> when (catalog.sourceType) { + CatalogSourceType.ADDON -> { + val addonLabel = catalog.addonName?.takeIf { it.isNotBlank() } ?: addonFallback + stringResource(R.string.settings_from_source, addonLabel) + } + CatalogSourceType.HOME_SERVER -> stringResource(R.string.settings_from_home_server) + else -> { + val base = catalog.sourceUrl ?: stringResource(R.string.settings_custom_catalog) + if (catalog.packName != null) { + "Pack: ${catalog.packName} • $base" + } else { + base + } + } + } + } val isSelected = selectedIds.contains(catalog.id) val layoutToggleEnabled = catalog.kind != CatalogKind.COLLECTION_RAIL val layoutRowKey = remember(catalog.id, catalog.kind) { catalogueLayoutRowKey(catalog) } @@ -9331,3 +9488,375 @@ private fun IptvCategoriesSettings( } } } + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun CatalogPackImportDialog( + pendingPack: CatalogPackManifest?, + isLoading: Boolean, + error: String?, + onConfirm: (CatalogPackManifest) -> Unit, + onDismiss: () -> Unit +) { + val focusRequester = remember { FocusRequester() } + var focusedIndex by remember(pendingPack) { mutableIntStateOf(0) } // 0 = Confirm, 1 = Cancel/Dismiss + val isTouchDevice = LocalDeviceType.current.isTouchDevice() + + LaunchedEffect(pendingPack, isLoading, error) { + focusRequester.requestFocus() + } + + androidx.compose.ui.window.Dialog( + onDismissRequest = onDismiss, + properties = androidx.compose.ui.window.DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false + ) + ) { + ModalScrim(onDismiss = onDismiss) { + Column( + modifier = Modifier + .then( + if (isTouchDevice) Modifier.fillMaxWidth(0.92f).widthIn(max = 480.dp) + else Modifier.width(480.dp) + ) + .background(BackgroundElevated, RoundedCornerShape(16.dp)) + .padding(if (isTouchDevice) 20.dp else 28.dp) + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown) { + when (event.key) { + Key.Back, Key.Escape -> { + onDismiss() + true + } + Key.DirectionLeft -> { + if (focusedIndex > 0) focusedIndex-- + true + } + Key.DirectionRight -> { + if (focusedIndex < 1) focusedIndex++ + true + } + Key.Enter, Key.DirectionCenter -> { + if (isLoading) { + // Ignore clicks while loading + } else if (error != null) { + onDismiss() + } else if (pendingPack != null) { + if (focusedIndex == 0) { + onConfirm(pendingPack) + } else { + onDismiss() + } + } + true + } + else -> false + } + } else false + } + ) { + if (isLoading) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LoadingIndicator(size = 40.dp) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Loading catalog pack details...", + style = ArflixTypography.body, + color = TextPrimary + ) + } + } else if (error != null) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Import Failed", + style = ArflixTypography.sectionTitle, + color = Color(0xFFDC2626) + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = error, + style = ArflixTypography.body, + color = TextSecondary + ) + Spacer(modifier = Modifier.height(24.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(Color.White) + .clickable { onDismiss() } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Close", + style = ArflixTypography.button, + color = Color.Black + ) + } + } + } else if (pendingPack != null) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Import Catalog Pack", + style = ArflixTypography.sectionTitle, + color = TextPrimary + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = pendingPack.name, + style = ArflixTypography.cardTitle.copy(fontSize = 18.sp), + color = resolveAccentColor(fallback = Pink) + ) + if (!pendingPack.author.isNullOrBlank()) { + Text( + text = "Author: ${pendingPack.author} • v${pendingPack.version ?: "1.0.0"}", + style = ArflixTypography.caption, + color = TextSecondary.copy(alpha = 0.8f) + ) + } + if (!pendingPack.description.isNullOrBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = pendingPack.description, + style = ArflixTypography.body.copy(fontSize = 14.sp), + color = TextSecondary + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Catalogs included (${pendingPack.catalogs.size}):", + style = ArflixTypography.caption, + color = TextSecondary.copy(alpha = 0.6f) + ) + Spacer(modifier = Modifier.height(8.dp)) + // Display a preview list of catalogs (scrollable if many) + val previewScrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 160.dp) + .verticalScroll(previewScrollState) + .background(Color.Black.copy(alpha = 0.25f), RoundedCornerShape(8.dp)) + .padding(8.dp) + ) { + pendingPack.catalogs.forEach { cat -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Widgets, + contentDescription = null, + tint = TextSecondary, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = cat.name, + style = ArflixTypography.body.copy(fontSize = 14.sp), + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + Spacer(modifier = Modifier.height(24.dp)) + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + val isConfirmFocused = focusedIndex == 0 + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(10.dp)) + .background( + color = if (isConfirmFocused) Color.White else Color.Black.copy(alpha = 0.82f), + shape = RoundedCornerShape(10.dp) + ) + .border( + width = 1.dp, + color = if (isConfirmFocused) Color.White else Color.White.copy(alpha = 0.14f), + shape = RoundedCornerShape(10.dp) + ) + .clickable { onConfirm(pendingPack) } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Install Pack", + style = ArflixTypography.button, + color = if (isConfirmFocused) Color.Black else Color.White + ) + } + + val isCancelFocused = focusedIndex == 1 + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(10.dp)) + .background( + color = if (isCancelFocused) Color.White else Color.Black.copy(alpha = 0.82f), + shape = RoundedCornerShape(10.dp) + ) + .border( + width = 1.dp, + color = if (isCancelFocused) Color.White else Color.White.copy(alpha = 0.14f), + shape = RoundedCornerShape(10.dp) + ) + .clickable { onDismiss() } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Cancel", + style = ArflixTypography.button, + color = if (isCancelFocused) Color.Black else Color.White + ) + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun CatalogPackDeleteConfirmDialog( + packName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + val focusRequester = remember { FocusRequester() } + var focusedIndex by remember { mutableIntStateOf(0) } // 0 = Confirm, 1 = Cancel + val isTouchDevice = LocalDeviceType.current.isTouchDevice() + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + androidx.compose.ui.window.Dialog( + onDismissRequest = onDismiss, + properties = androidx.compose.ui.window.DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false + ) + ) { + ModalScrim(onDismiss = onDismiss) { + Column( + modifier = Modifier + .then( + if (isTouchDevice) Modifier.fillMaxWidth(0.92f).widthIn(max = 400.dp) + else Modifier.width(400.dp) + ) + .background(BackgroundElevated, RoundedCornerShape(16.dp)) + .padding(if (isTouchDevice) 20.dp else 28.dp) + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown) { + when (event.key) { + Key.Back, Key.Escape -> { + onDismiss() + true + } + Key.DirectionLeft -> { + if (focusedIndex > 0) focusedIndex-- + true + } + Key.DirectionRight -> { + if (focusedIndex < 1) focusedIndex++ + true + } + Key.Enter, Key.DirectionCenter -> { + if (focusedIndex == 0) onConfirm() else onDismiss() + true + } + else -> false + } + } else false + } + ) { + Text( + text = "Delete Catalog Pack", + style = ArflixTypography.sectionTitle, + color = Color(0xFFDC2626) + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Do you want to delete the pack \"$packName\"? This will remove all catalogs that were imported with this pack.", + style = ArflixTypography.body, + color = TextSecondary + ) + Spacer(modifier = Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + val isConfirmFocused = focusedIndex == 0 + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(10.dp)) + .background( + color = if (isConfirmFocused) Color.White else Color.Black.copy(alpha = 0.82f), + shape = RoundedCornerShape(10.dp) + ) + .border( + width = 1.dp, + color = if (isConfirmFocused) Color.White else Color.White.copy(alpha = 0.14f), + shape = RoundedCornerShape(10.dp) + ) + .clickable { onConfirm() } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Delete Pack", + style = ArflixTypography.button, + color = if (isConfirmFocused) Color.Black else Color.White + ) + } + + val isCancelFocused = focusedIndex == 1 + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(10.dp)) + .background( + color = if (isCancelFocused) Color.White else Color.Black.copy(alpha = 0.82f), + shape = RoundedCornerShape(10.dp) + ) + .border( + width = 1.dp, + color = if (isCancelFocused) Color.White else Color.White.copy(alpha = 0.14f), + shape = RoundedCornerShape(10.dp) + ) + .clickable { onDismiss() } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Cancel", + style = ArflixTypography.button, + color = if (isCancelFocused) Color.Black else Color.White + ) + } + } + } + } + } +} 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 e5e9b6473..d2d4d7633 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 @@ -18,6 +18,7 @@ import com.arflix.tv.data.model.Addon import com.arflix.tv.data.model.CatalogConfig import com.arflix.tv.data.model.CatalogDiscoveryResult import com.arflix.tv.data.model.CatalogKind +import com.arflix.tv.data.model.CatalogPackManifest import com.arflix.tv.data.model.Profile import com.arflix.tv.data.model.QualityFilterConfig import com.arflix.tv.data.repository.AuthRepository @@ -169,6 +170,10 @@ data class SettingsUiState( val catalogSearchResults: List = emptyList(), val isCatalogSearching: Boolean = false, val catalogSearchError: String? = null, + val pendingPackManifest: CatalogPackManifest? = null, + val pendingPackUrl: String? = null, + val isPackLoading: Boolean = false, + val packError: String? = null, // Addons val addons: List = emptyList(), val torrServerBaseUrl: String = "", @@ -1780,6 +1785,83 @@ class SettingsViewModel @Inject constructor( } } + fun loadPackManifest(url: String) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isPackLoading = true, + packError = null, + pendingPackManifest = null, + pendingPackUrl = null + ) + val result = catalogRepository.fetchCatalogPackManifest(url) + result.onSuccess { manifest -> + _uiState.value = _uiState.value.copy( + isPackLoading = false, + pendingPackManifest = manifest, + pendingPackUrl = url + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isPackLoading = false, + packError = error.message ?: "Failed to load pack manifest", + pendingPackUrl = null + ) + } + } + } + + fun clearPendingPack() { + _uiState.value = _uiState.value.copy( + pendingPackManifest = null, + pendingPackUrl = null, + isPackLoading = false, + packError = null + ) + } + + fun confirmInstallPack(url: String) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isPackLoading = true, + packError = null + ) + val result = catalogRepository.addCatalogPack(url) + result.onSuccess { manifest -> + _uiState.value = _uiState.value.copy( + isPackLoading = false, + pendingPackManifest = null, + pendingPackUrl = null, + toastMessage = "Installed pack: ${manifest.name}", + toastType = ToastType.SUCCESS + ) + syncLocalStateToCloud(silent = true) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isPackLoading = false, + packError = error.message ?: "Failed to install pack" + ) + } + } + } + + fun removeCatalogPack(packId: String) { + viewModelScope.launch { + val result = catalogRepository.removeCatalogPack(packId) + result.onSuccess { + _uiState.value = _uiState.value.copy( + toastMessage = "Pack removed", + toastType = ToastType.SUCCESS + ) + syncLocalStateToCloud(silent = true) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + toastMessage = error.message ?: "Failed to remove pack", + toastType = ToastType.ERROR + ) + } + } + } + fun addCatalog(url: String) { viewModelScope.launch { val result = catalogRepository.addCustomCatalog(url) From 263fdc06cd5c5fa14c7475f978dafe8657a2caea Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Sun, 19 Jul 2026 22:02:24 +0530 Subject: [PATCH 2/9] feat(catalog-packs): add virtual pack support & dynamic catalog pack discovery page --- .../com/arflix/tv/data/model/CatalogModels.kt | 27 + .../tv/ui/screens/settings/SettingsScreen.kt | 93 +- web/app/discover/page.tsx | 946 ++++++++++++++++++ web/public/packs.json | 44 + 4 files changed, 1059 insertions(+), 51 deletions(-) create mode 100644 web/app/discover/page.tsx create mode 100644 web/public/packs.json diff --git a/app/src/main/kotlin/com/arflix/tv/data/model/CatalogModels.kt b/app/src/main/kotlin/com/arflix/tv/data/model/CatalogModels.kt index 46ad73b77..702eabcf8 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/model/CatalogModels.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/model/CatalogModels.kt @@ -105,6 +105,33 @@ data class CatalogConfig( val packName: String? = null ) : Serializable +val CatalogConfig.effectivePackId: String + get() = packId ?: when (sourceType) { + CatalogSourceType.PREINSTALLED -> "system" + CatalogSourceType.ADDON -> "addon" + CatalogSourceType.TRAKT -> "trakt" + CatalogSourceType.MDBLIST -> "mdblist" + CatalogSourceType.HOME_SERVER -> "home_server" + } + +val CatalogConfig.effectivePackName: String + get() = packName ?: when (sourceType) { + CatalogSourceType.PREINSTALLED -> "System Catalogs" + CatalogSourceType.ADDON -> "Addon Catalogs" + CatalogSourceType.TRAKT -> "Trakt Catalogs" + CatalogSourceType.MDBLIST -> "MDBlist Catalogs" + CatalogSourceType.HOME_SERVER -> "Home Server Catalogs" + } + +val CatalogConfig.isBulkDeletablePack: Boolean + get() = packId != null && + packId != "system" && + packId != "addon" && + packId != "trakt" && + packId != "mdblist" && + packId != "home_server" && + packId != "individual" + data class CatalogDiscoveryResult( val id: String, val title: String, 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 5784726eb..fdf849165 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 @@ -154,6 +154,9 @@ import com.arflix.tv.data.model.CatalogConfig import com.arflix.tv.data.model.CatalogDiscoveryResult import com.arflix.tv.data.model.CatalogKind import com.arflix.tv.data.model.CatalogPackManifest +import com.arflix.tv.data.model.effectivePackId +import com.arflix.tv.data.model.effectivePackName +import com.arflix.tv.data.model.isBulkDeletablePack import com.arflix.tv.data.model.CatalogSourceType import com.arflix.tv.data.model.QualityFilterConfig import com.arflix.tv.data.model.RuntimeKind @@ -1060,9 +1063,9 @@ fun SettingsScreen( } } else -> { - if (catalog.packId != null) { - deletePackId = catalog.packId - deletePackName = catalog.packName ?: "" + if (catalog.isBulkDeletablePack) { + deletePackId = catalog.effectivePackId + deletePackName = catalog.effectivePackName showDeletePackConfirm = true } else { viewModel.removeCatalog(catalog.id) @@ -1165,9 +1168,9 @@ fun SettingsScreen( showCatalogRename = true }, onDeleteCatalogClick = { catalog -> - if (catalog.packId != null) { - deletePackId = catalog.packId - deletePackName = catalog.packName ?: "" + if (catalog.isBulkDeletablePack) { + deletePackId = catalog.effectivePackId + deletePackName = catalog.effectivePackName showDeletePackConfirm = true } else { viewModel.removeCatalog(catalog.id) @@ -1581,9 +1584,9 @@ fun SettingsScreen( onMoveCatalogUp = { catalog -> viewModel.moveCatalogUp(catalog.id) }, onMoveCatalogDown = { catalog -> viewModel.moveCatalogDown(catalog.id) }, onDeleteCatalog = { catalog -> - if (catalog.packId != null) { - deletePackId = catalog.packId - deletePackName = catalog.packName ?: "" + if (catalog.isBulkDeletablePack) { + deletePackId = catalog.effectivePackId + deletePackName = catalog.effectivePackName showDeletePackConfirm = true } else { viewModel.removeCatalog(catalog.id) @@ -7155,31 +7158,25 @@ private fun CatalogsSettings( val title = if (catalog.isPreinstalled) { when (catalog.kind) { CatalogKind.COLLECTION -> stringResource(R.string.settings_title_builtin_collection, catalog.title); CatalogKind.COLLECTION_RAIL -> stringResource(R.string.settings_title_builtin_rail, catalog.title); else -> stringResource(R.string.settings_title_builtin, catalog.title) } } else catalog.title val collectionFallback = stringResource(R.string.settings_collection_fallback) val addonFallback = stringResource(R.string.settings_source_addon) - val subtitle = when { - catalog.kind == CatalogKind.COLLECTION_RAIL -> { - val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback - stringResource(R.string.settings_group_rail, group) - } - catalog.kind == CatalogKind.COLLECTION -> { - val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback - stringResource(R.string.settings_group_collection, group) - } - catalog.sourceType == CatalogSourceType.PREINSTALLED -> stringResource(R.string.settings_preinstalled_catalog) - else -> when (catalog.sourceType) { - CatalogSourceType.ADDON -> { + val subtitle = run { + val baseSubtitle = when { + catalog.kind == CatalogKind.COLLECTION_RAIL -> { + val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback + stringResource(R.string.settings_group_rail, group) + } + catalog.kind == CatalogKind.COLLECTION -> { + val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback + stringResource(R.string.settings_group_collection, group) + } + catalog.sourceType == CatalogSourceType.PREINSTALLED -> stringResource(R.string.settings_preinstalled_catalog) + catalog.sourceType == CatalogSourceType.ADDON -> { val addonLabel = catalog.addonName?.takeIf { it.isNotBlank() } ?: addonFallback stringResource(R.string.settings_from_source, addonLabel) } - CatalogSourceType.HOME_SERVER -> stringResource(R.string.settings_from_home_server) - else -> { - val base = catalog.sourceUrl ?: stringResource(R.string.settings_custom_catalog) - if (catalog.packName != null) { - "Pack: ${catalog.packName} • $base" - } else { - base - } - } + catalog.sourceType == CatalogSourceType.HOME_SERVER -> stringResource(R.string.settings_from_home_server) + else -> catalog.sourceUrl ?: stringResource(R.string.settings_custom_catalog) } + "Pack: ${catalog.effectivePackName} • $baseSubtitle" } val isSelected = selectedIds.contains(catalog.id) val layoutToggleEnabled = catalog.kind != CatalogKind.COLLECTION_RAIL @@ -7246,31 +7243,25 @@ private fun CatalogsSettings( val title = if (catalog.isPreinstalled) { when (catalog.kind) { CatalogKind.COLLECTION -> stringResource(R.string.settings_title_builtin_collection, catalog.title); CatalogKind.COLLECTION_RAIL -> stringResource(R.string.settings_title_builtin_rail, catalog.title); else -> stringResource(R.string.settings_title_builtin, catalog.title) } } else catalog.title val collectionFallback = stringResource(R.string.settings_collection_fallback) val addonFallback = stringResource(R.string.settings_source_addon) - val subtitle = when { - catalog.kind == CatalogKind.COLLECTION_RAIL -> { - val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback - stringResource(R.string.settings_group_rail, group) - } - catalog.kind == CatalogKind.COLLECTION -> { - val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback - stringResource(R.string.settings_group_collection, group) - } - catalog.sourceType == CatalogSourceType.PREINSTALLED -> stringResource(R.string.settings_preinstalled_catalog) - else -> when (catalog.sourceType) { - CatalogSourceType.ADDON -> { + val subtitle = run { + val baseSubtitle = when { + catalog.kind == CatalogKind.COLLECTION_RAIL -> { + val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback + stringResource(R.string.settings_group_rail, group) + } + catalog.kind == CatalogKind.COLLECTION -> { + val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback + stringResource(R.string.settings_group_collection, group) + } + catalog.sourceType == CatalogSourceType.PREINSTALLED -> stringResource(R.string.settings_preinstalled_catalog) + catalog.sourceType == CatalogSourceType.ADDON -> { val addonLabel = catalog.addonName?.takeIf { it.isNotBlank() } ?: addonFallback stringResource(R.string.settings_from_source, addonLabel) } - CatalogSourceType.HOME_SERVER -> stringResource(R.string.settings_from_home_server) - else -> { - val base = catalog.sourceUrl ?: stringResource(R.string.settings_custom_catalog) - if (catalog.packName != null) { - "Pack: ${catalog.packName} • $base" - } else { - base - } - } + catalog.sourceType == CatalogSourceType.HOME_SERVER -> stringResource(R.string.settings_from_home_server) + else -> catalog.sourceUrl ?: stringResource(R.string.settings_custom_catalog) } + "Pack: ${catalog.effectivePackName} • $baseSubtitle" } val isSelected = selectedIds.contains(catalog.id) val layoutToggleEnabled = catalog.kind != CatalogKind.COLLECTION_RAIL diff --git a/web/app/discover/page.tsx b/web/app/discover/page.tsx new file mode 100644 index 000000000..f90035c47 --- /dev/null +++ b/web/app/discover/page.tsx @@ -0,0 +1,946 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Copy, Check, Download, HelpCircle, Code, ListVideo, Loader2, Plus, X, Globe, User, BookOpen, AlertCircle } from "lucide-react"; +import { config, hasSupabaseConfig } from "@/lib/config"; + +export const dynamic = "force-static"; + +interface CatalogPack { + id?: string; + name: string; + author: string; + version: string; + description: string; + url: string; + catalogs: string[]; + status?: "pending" | "approved"; +} + +const STATIC_FALLBACK_PACKS: CatalogPack[] = [ + { + id: "cinema-essentials", + name: "Cinema Essentials", + author: "ARVIO Team", + version: "1.0.0", + description: "All the trending movies, popular lists, and upcoming releases you need for a perfect movie night.", + url: "https://arvio.app/packs/cinema-essentials.json", + catalogs: ["Trending in Movies", "Top 10 Movies Today", "Top Movies This Week", "Coming Soon"] + }, + { + id: "tv-binge", + name: "TV Show Binge Pack", + author: "ARVIO Team", + version: "1.0.0", + description: "Never miss an episode. Popular, trending, and latest airing series in one convenient bundle.", + url: "https://arvio.app/packs/tv-binge.json", + catalogs: ["Trending in Shows", "Top 10 Shows Today", "Latest Airing"] + }, + { + id: "anime-kdrama", + name: "Otaku & K-Drama Hub", + author: "Community", + version: "1.1.2", + description: "The ultimate pack for anime lovers and K-Drama fans. Auto-updated lists of trending episodes and releases.", + url: "https://arvio.app/packs/anime-kdrama.json", + catalogs: ["Trending in Anime", "New in K-Dramas"] + }, + { + id: "classics-franchises", + name: "Action & Franchise Classics", + author: "Cinephile", + version: "1.0.5", + description: "Full box-sets and classic franchise collections, including James Bond, Harry Potter, Lord of the Rings, and Jurassic Park.", + url: "https://arvio.app/packs/classics-franchises.json", + catalogs: [ + "James Bond Collection", + "Harry Potter Collection", + "The Matrix Collection", + "Lord of the Rings and Hobbit Collection", + "Jurassic Park Collection" + ] + } +]; + +export default function DiscoverPage() { + const [packs, setPacks] = useState(STATIC_FALLBACK_PACKS); + const [loading, setLoading] = useState(true); + const [copiedId, setCopiedId] = useState(null); + + // Form States + const [showSubmitModal, setShowSubmitModal] = useState(false); + const [formName, setFormName] = useState(""); + const [formAuthor, setFormAuthor] = useState(""); + const [formUrl, setFormUrl] = useState(""); + const [formDesc, setFormDesc] = useState(""); + const [formCatalogs, setFormCatalogs] = useState(""); + const [submitLoading, setSubmitLoading] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [submitSuccess, setSubmitSuccess] = useState(false); + + async function loadPacks() { + try { + if (hasSupabaseConfig()) { + const res = await fetch(`${config.supabaseUrl}/rest/v1/catalog_packs?status=eq.approved&select=*`, { + headers: { + apikey: config.supabaseAnonKey, + Accept: "application/json" + } + }); + if (res.ok) { + const data = await res.json(); + const parsed = data.map((item: any) => ({ + ...item, + catalogs: typeof item.catalogs === "string" ? JSON.parse(item.catalogs) : item.catalogs + })); + if (Array.isArray(parsed) && parsed.length > 0) { + setPacks(parsed); + setLoading(false); + return; + } + } + } + } catch (err) { + console.warn("Supabase fetch failed, falling back to static packs.json:", err); + } + + // Fallback: fetch from /packs.json + try { + const res = await fetch("/packs.json"); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data) && data.length > 0) { + setPacks(data); + } + } + } catch (err) { + console.error("Local packs.json fetch failed, using default hardcoded list:", err); + } + setLoading(false); + } + + useEffect(() => { + void loadPacks(); + }, []); + + const copyToClipboard = (id: string, text: string) => { + navigator.clipboard.writeText(text).then(() => { + setCopiedId(id); + setTimeout(() => setCopiedId(null), 2000); + }); + }; + + const getDeepLink = (packUrl: string) => { + return `arvio://install-pack?url=${encodeURIComponent(packUrl)}`; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!formName.trim() || !formUrl.trim() || !formDesc.trim() || !formCatalogs.trim()) { + setSubmitError("Please fill out all required fields."); + return; + } + + // Basic URL validation + if (!formUrl.startsWith("http://") && !formUrl.startsWith("https://")) { + setSubmitError("Manifest URL must start with http:// or https://"); + return; + } + + setSubmitLoading(true); + setSubmitError(null); + + const catalogList = formCatalogs + .split(",") + .map((c) => c.trim()) + .filter(Boolean); + + try { + if (!hasSupabaseConfig()) { + throw new Error("Supabase is not configured on the website backend. Please submit packs via GitHub."); + } + + const res = await fetch(`${config.supabaseUrl}/rest/v1/catalog_packs`, { + method: "POST", + headers: { + apikey: config.supabaseAnonKey, + "Content-Type": "application/json", + Prefer: "return=minimal" + }, + body: JSON.stringify({ + name: formName.trim(), + url: formUrl.trim(), + author: formAuthor.trim() || "Anonymous", + version: "1.0.0", + description: formDesc.trim(), + catalogs: catalogList, + status: "pending" // Require moderator approval + }) + }); + + if (!res.ok) { + throw new Error(`Failed to submit: ${res.statusText}`); + } + + setSubmitSuccess(true); + setFormName(""); + setFormAuthor(""); + setFormUrl(""); + setFormDesc(""); + setFormCatalogs(""); + setTimeout(() => { + setSubmitSuccess(false); + setShowSubmitModal(false); + }, 3000); + } catch (err: any) { + setSubmitError(err.message || "An unexpected error occurred during submission."); + } finally { + setSubmitLoading(false); + } + }; + + return ( +
+
+
+
+ ARVIO Logo + ARVIO +
+ +
+ +

Catalog Pack Discovery

+

+ Enhance your streaming layout. Browse community-curated catalog packs and install multiple rows of content with a single click. +

+
+ + {loading ? ( +
+ + Fetching latest catalog packs... +
+ ) : ( +
+ {packs.map((pack) => ( +
+
+

{pack.name}

+
+ by {pack.author || "Anonymous"} + v{pack.version || "1.0.0"} +
+
+

{pack.description}

+ +
+

+ Included Rows ({pack.catalogs?.length || 0}) +

+
    + {pack.catalogs?.map((catalog, idx) => ( +
  • {catalog}
  • + ))} +
+
+ +
+ + Install Pack + + +
+
+ ))} +
+ )} + + {/* Submission Modal Overlay */} + {showSubmitModal && ( +
+
+ + + {submitSuccess ? ( +
+ +

Submission Successful!

+

+ Thank you! Your catalog pack has been submitted for review. It will become visible on the Discovery page once approved by a moderator. +

+
+ ) : ( +
+

Submit a Catalog Pack

+

+ Packs are moderated to ensure safety, formatting correctness, and server reliability. +

+ + {submitError && ( +
+ + {submitError} +
+ )} + +
+ + setFormName(e.target.value)} + /> +
+ +
+ + setFormUrl(e.target.value)} + /> + Must be public, CORS-enabled, and serve valid JSON. +
+ +
+
+ + setFormAuthor(e.target.value)} + /> +
+
+ +
+ +