diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index efefa492d..41cadd65b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -212,9 +212,11 @@ fun App() { } val authState by AuthRepository.state.collectAsStateWithLifecycle() + val profileState by ProfileRepository.state.collectAsStateWithLifecycle() var gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) } var editingProfile by remember { mutableStateOf(null) } var isNewProfile by remember { mutableStateOf(false) } + var autoSkipProfileSelection by rememberSaveable { mutableStateOf(false) } LaunchedEffect(authState) { when (authState) { @@ -227,12 +229,36 @@ fun App() { val authenticatedState = authState as AuthState.Authenticated ProfileRepository.ensureLoaded(authenticatedState.userId) if (gateScreen == AppGateScreen.Loading.name || gateScreen == AppGateScreen.Auth.name) { - gateScreen = AppGateScreen.ProfileSelection.name + autoSkipProfileSelection = true + val cachedProfiles = ProfileRepository.state.value.profiles + if (cachedProfiles.size == 1) { + val onlyProfile = cachedProfiles.first() + ProfileRepository.selectProfile(onlyProfile.profileIndex) + SyncManager.pullAllForProfile(onlyProfile.profileIndex) + gateScreen = AppGateScreen.Main.name + autoSkipProfileSelection = false + } else { + gateScreen = AppGateScreen.ProfileSelection.name + } } } } } + LaunchedEffect(gateScreen, autoSkipProfileSelection, profileState.profiles) { + if ( + autoSkipProfileSelection && + gateScreen == AppGateScreen.ProfileSelection.name && + profileState.profiles.size == 1 + ) { + val onlyProfile = profileState.profiles.first() + ProfileRepository.selectProfile(onlyProfile.profileIndex) + SyncManager.pullAllForProfile(onlyProfile.profileIndex) + gateScreen = AppGateScreen.Main.name + autoSkipProfileSelection = false + } + } + AnimatedContent( targetState = gateScreen, label = "app_gate", @@ -286,6 +312,7 @@ fun App() { AppGateScreen.Main.name -> { MainAppContent( onSwitchProfile = { + autoSkipProfileSelection = false gateScreen = AppGateScreen.ProfileSelection.name }, ) @@ -478,6 +505,7 @@ private fun MainAppContent( selected = selectedTab == AppScreenTab.Settings, onClick = { selectedTab = AppScreenTab.Settings }, onProfileSelected = onProfileSelected, + onAddProfileRequested = onSwitchProfile, ) }, label = { Text("Profile") }, @@ -521,6 +549,7 @@ private fun MainAppContent( selectedTab = selectedTab, onTabSelected = { selectedTab = it }, onProfileSelected = onProfileSelected, + onAddProfileRequested = onSwitchProfile, ) } } @@ -966,6 +995,7 @@ private fun TabletFloatingTopBar( selectedTab: AppScreenTab, onTabSelected: (AppScreenTab) -> Unit, onProfileSelected: (NuvioProfile) -> Unit, + onAddProfileRequested: () -> Unit, modifier: Modifier = Modifier, ) { val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() @@ -1040,6 +1070,7 @@ private fun TabletFloatingTopBar( selected = selectedTab == AppScreenTab.Settings, onClick = { onTabSelected(AppScreenTab.Settings) }, onProfileSelected = onProfileSelected, + onAddProfileRequested = onAddProfileRequested, ) Text( text = "Profile", diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt index 801176953..d8b00417f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt @@ -11,7 +11,7 @@ internal object PluginManifestParser { val manifest = json.decodeFromString(payload) require(manifest.name.isNotBlank()) { "Manifest name is missing." } require(manifest.version.isNotBlank()) { "Manifest version is missing." } - require(manifest.scrapers.isNotEmpty()) { "Manifest has no scrapers." } + require(manifest.scrapers.isNotEmpty()) { "Manifest has no providers." } return manifest } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt index 41d09dfcf..925dda065 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt @@ -293,7 +293,7 @@ object PluginRepository { suspend fun testScraper(scraperId: String): Result> { initialize() val scraper = _uiState.value.scrapers.find { it.id == scraperId } - ?: return Result.failure(IllegalArgumentException("Scraper not found")) + ?: return Result.failure(IllegalArgumentException("Provider not found")) val mediaType = if (scraper.supportsType("movie")) "movie" else "tv" val season = if (mediaType == "tv") 1 else null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt index 21521bd1e..4838ab51c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt @@ -84,7 +84,7 @@ fun PluginsSettingsPageContent( horizontalArrangement = Arrangement.spacedBy(10.dp), ) { NuvioInfoBadge(text = "${sortedRepos.size} repos") - NuvioInfoBadge(text = "${sortedScrapers.size} scrapers") + NuvioInfoBadge(text = "${sortedScrapers.size} providers") NuvioInfoBadge( text = if (uiState.pluginsEnabled) "Plugins enabled" else "Plugins disabled", ) @@ -97,13 +97,13 @@ fun PluginsSettingsPageContent( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = "Enable plugin scrapers globally", + text = "Enable plugin providers globally", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Use JavaScript plugin scrapers during stream discovery.", + text = "Use plugin providers during stream discovery.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -132,7 +132,7 @@ fun PluginsSettingsPageContent( ) Spacer(modifier = Modifier.height(2.dp)) Text( - text = "In Streams, show one provider per repo (example: Tapframe) instead of one per scraper.", + text = "In Streams, show one provider per repository instead of one per source.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -201,7 +201,7 @@ fun PluginsSettingsPageContent( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Add a repository URL to download JS scrapers and use them in stream discovery.", + text = "Add a repository URL to install provider plugins for stream discovery.", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -259,7 +259,7 @@ fun PluginsSettingsPageContent( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - NuvioInfoBadge(text = "${repo.scraperCount} scrapers") + NuvioInfoBadge(text = "${repo.scraperCount} providers") if (repo.isRefreshing) { NuvioInfoBadge(text = "Refreshing") } @@ -276,11 +276,11 @@ fun PluginsSettingsPageContent( } } - NuvioSectionLabel("SCRAPERS") + NuvioSectionLabel("PROVIDERS") if (sortedScrapers.isEmpty()) { NuvioSurfaceCard { Text( - text = "No scrapers available yet.", + text = "No providers available yet.", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -354,7 +354,7 @@ fun PluginsSettingsPageContent( Spacer(modifier = Modifier.height(12.dp)) NuvioPrimaryButton( - text = if (isTestingThisScraper) "Testing..." else "Test Scraper", + text = if (isTestingThisScraper) "Testing..." else "Test Provider", enabled = !isTestingThisScraper, onClick = { testingScraperId = scraper.id @@ -367,7 +367,7 @@ fun PluginsSettingsPageContent( testResults[scraper.id] = listOf( PluginRuntimeResult( title = "Error", - name = error.message ?: "Scraper test failed", + name = error.message ?: "Provider test failed", url = "about:error", ), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index d8ef3e3cf..a7b93288d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Backspace +import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.Person import androidx.compose.material3.Icon @@ -71,6 +72,7 @@ fun ProfileSwitcherTab( selected: Boolean, onClick: () -> Unit, onProfileSelected: (NuvioProfile) -> Unit, + onAddProfileRequested: () -> Unit, modifier: Modifier = Modifier, ) { val profileState by ProfileRepository.state.collectAsStateWithLifecycle() @@ -133,7 +135,7 @@ fun ProfileSwitcherTab( detectTapGestures( onTap = { onClick() }, onLongPress = { - if (profiles.size > 1) { + if (profiles.isNotEmpty()) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) showPopup = true } @@ -151,7 +153,7 @@ fun ProfileSwitcherTab( ) // Floating profile popup (stays composed during exit animation) - if (popupVisible && profiles.size > 1) { + if (popupVisible && profiles.isNotEmpty()) { Popup( alignment = Alignment.BottomCenter, offset = IntOffset(0, with(density) { -64.dp.roundToPx() }), @@ -202,6 +204,16 @@ fun ProfileSwitcherTab( }, ) } + + if (profiles.size < 4) { + PopupAddProfileBubble( + delayMs = profiles.size * 50, + onClick = { + showPopup = false + onAddProfileRequested() + }, + ) + } } // Inline PIN entry for locked profiles @@ -236,6 +248,74 @@ fun ProfileSwitcherTab( } } +@Composable +private fun PopupAddProfileBubble( + delayMs: Int, + onClick: () -> Unit, +) { + val itemAlpha = remember { Animatable(0f) } + val itemScale = remember { Animatable(0.4f) } + + LaunchedEffect(Unit) { + delay(delayMs.toLong()) + launch { itemAlpha.animateTo(1f, tween(200, easing = FastOutSlowInEasing)) } + launch { + itemScale.animateTo( + 1f, + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + ) + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .graphicsLayer { + alpha = itemAlpha.value + scaleX = itemScale.value + scaleY = itemScale.value + } + .clip(RoundedCornerShape(16.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .padding(4.dp), + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .border(1.5.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.4f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = "Add Profile", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "Add", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + maxLines = 1, + modifier = Modifier.width(56.dp), + ) + } +} + @Composable private fun PopupProfileBubble( profile: NuvioProfile, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt index a7d0cecd0..1460efaa8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt @@ -2,13 +2,10 @@ package com.nuvio.app.features.settings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -33,13 +30,8 @@ internal fun LazyListScope.traktSettingsContent( uiState: TraktAuthUiState, ) { item { - SettingsSection( - title = "TRAKT", - isTablet = isTablet, - ) { - SettingsGroup(isTablet = isTablet) { - TraktBrandIntro(isTablet = isTablet) - } + SettingsGroup(isTablet = isTablet) { + TraktBrandIntro(isTablet = isTablet) } } @@ -69,35 +61,26 @@ private fun TraktBrandIntro( modifier = Modifier .fillMaxWidth() .padding(horizontal = horizontalPadding, vertical = verticalPadding), - verticalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.Start, ) { Row( - modifier = Modifier - .fillMaxWidth() - .height(if (isTablet) 64.dp else 56.dp), + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), ) { androidx.compose.foundation.Image( painter = traktBrandPainter(TraktBrandAsset.Glyph), contentDescription = "Trakt", - modifier = Modifier.size(if (isTablet) 56.dp else 48.dp), + modifier = Modifier.size(if (isTablet) 84.dp else 72.dp), contentScale = ContentScale.Fit, ) - androidx.compose.foundation.Image( - painter = traktBrandPainter(TraktBrandAsset.Wordmark), - contentDescription = "Trakt", - modifier = Modifier - .fillMaxHeight() - .width(if (isTablet) 170.dp else 150.dp), - contentScale = ContentScale.Fit, + Text( + text = "Track what you watch, save to watchlist or custom lists, and keep your library synced with Trakt.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Text( - text = "Track what you watch, save to watchlist or custom lists, and keep your library synced with Trakt.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) } }