mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
feat(profile): add profile selection logic and add profile creation option
This commit is contained in:
parent
254d912f3c
commit
511804baab
6 changed files with 138 additions and 44 deletions
|
|
@ -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<NuvioProfile?>(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",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ internal object PluginManifestParser {
|
|||
val manifest = json.decodeFromString<PluginManifest>(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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -293,7 +293,7 @@ object PluginRepository {
|
|||
suspend fun testScraper(scraperId: String): Result<List<PluginRuntimeResult>> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue