Merge pull request #779 from NuvioMedia/cmp-rewrite

Cmp rewrite
This commit is contained in:
Nayif 2026-04-19 00:06:12 +05:30 committed by GitHub
commit c2188af1a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1585 additions and 50 deletions

View file

@ -88,6 +88,20 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
""".trimMargin()
)
}
outDir.resolve("com/nuvio/app/features/settings").apply {
mkdirs()
resolve("CommunityConfig.kt").writeText(
"""
|package com.nuvio.app.features.settings
|
|object CommunityConfig {
| const val DONATIONS_BASE_URL = "${props.getProperty("DONATIONS_BASE_URL", "")}"
| const val DONATIONS_DONATE_URL = "${props.getProperty("DONATIONS_DONATE_URL", "")}"
|}
""".trimMargin()
)
}
}
}

View file

@ -25,6 +25,8 @@ import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsStorage
import com.nuvio.app.features.player.PlayerSettingsStorage
import com.nuvio.app.features.player.PlayerPictureInPictureManager
import com.nuvio.app.features.plugins.PluginStorage
import com.nuvio.app.features.profiles.AvatarStorage
import com.nuvio.app.features.profiles.ProfilePinCacheStorage
import com.nuvio.app.features.profiles.ProfileStorage
import com.nuvio.app.features.details.SeasonViewModeStorage
import com.nuvio.app.features.search.SearchHistoryStorage
@ -59,6 +61,8 @@ class MainActivity : ComponentActivity() {
HomeCatalogSettingsStorage.initialize(applicationContext)
PlayerSettingsStorage.initialize(applicationContext)
ProfileStorage.initialize(applicationContext)
AvatarStorage.initialize(applicationContext)
ProfilePinCacheStorage.initialize(applicationContext)
SearchHistoryStorage.initialize(applicationContext)
SeasonViewModeStorage.initialize(applicationContext)
ThemeSettingsStorage.initialize(applicationContext)

View file

@ -9,6 +9,8 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_home_catalog_settings",
"nuvio_player_settings",
"nuvio_profile_cache",
"nuvio_avatar_cache",
"nuvio_profile_pin_cache",
"nuvio_theme_settings",
"nuvio_poster_card_style",
"nuvio_mdblist_settings",

View file

@ -0,0 +1,25 @@
package com.nuvio.app.features.profiles
import android.content.Context
import android.content.SharedPreferences
actual object AvatarStorage {
private const val preferencesName = "nuvio_avatar_cache"
private const val payloadKey = "avatar_catalog_payload"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun loadPayload(): String? =
preferences?.getString(payloadKey, null)
actual fun savePayload(payload: String) {
preferences
?.edit()
?.putString(payloadKey, payload)
?.apply()
}
}

View file

@ -0,0 +1,33 @@
package com.nuvio.app.features.profiles
import android.content.Context
import android.content.SharedPreferences
actual object ProfilePinCacheStorage {
private const val preferencesName = "nuvio_profile_pin_cache"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun loadPayload(profileIndex: Int): String? =
preferences?.getString(payloadKey(profileIndex), null)
actual fun savePayload(profileIndex: Int, payload: String) {
preferences
?.edit()
?.putString(payloadKey(profileIndex), payload)
?.apply()
}
actual fun removePayload(profileIndex: Int) {
preferences
?.edit()
?.remove(payloadKey(profileIndex))
?.apply()
}
private fun payloadKey(profileIndex: Int): String = "profile_pin_cache_$profileIndex"
}

View file

@ -0,0 +1,12 @@
package com.nuvio.app.features.profiles
import java.security.MessageDigest
actual object ProfilePinCrypto {
actual fun sha256Hex(value: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(value.encodeToByteArray())
return digest.joinToString(separator = "") { byte ->
byte.toUByte().toString(16).padStart(2, '0')
}
}
}

View file

@ -134,6 +134,7 @@ import com.nuvio.app.features.settings.ContinueWatchingSettingsScreen
import com.nuvio.app.features.settings.AddonsSettingsScreen
import com.nuvio.app.features.settings.PluginsSettingsScreen
import com.nuvio.app.features.settings.AccountSettingsScreen
import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen
import com.nuvio.app.features.settings.ThemeSettingsRepository
import com.nuvio.app.features.collection.CollectionManagementScreen
import com.nuvio.app.features.collection.CollectionEditorScreen
@ -215,6 +216,9 @@ object PluginsSettingsRoute
@Serializable
object AccountSettingsRoute
@Serializable
object SupportersContributorsSettingsRoute
@Serializable
object CollectionsRoute
@ -278,40 +282,81 @@ fun App() {
AuthRepository.initialize()
}
LaunchedEffect(Unit) {
NetworkStatusRepository.ensureStarted()
ProfileRepository.loadCachedProfiles()
}
val authState by AuthRepository.state.collectAsStateWithLifecycle()
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val networkStatusUiState by remember {
NetworkStatusRepository.uiState
}.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) {
fun enterProfileGate(profiles: List<NuvioProfile>, syncOnEnter: Boolean) {
if (profiles.isEmpty()) {
autoSkipProfileSelection = true
gateScreen = AppGateScreen.ProfileSelection.name
return
}
autoSkipProfileSelection = true
if (profiles.size == 1) {
val onlyProfile = profiles.first()
ProfileRepository.selectProfile(onlyProfile.profileIndex)
if (syncOnEnter) {
SyncManager.pullAllForProfile(onlyProfile.profileIndex)
}
gateScreen = AppGateScreen.Main.name
autoSkipProfileSelection = false
} else {
gateScreen = AppGateScreen.ProfileSelection.name
}
}
LaunchedEffect(authState, networkStatusUiState.condition, profileState.profiles) {
val cachedProfiles = profileState.profiles
val allowOfflineProfileAccess =
cachedProfiles.isNotEmpty() &&
authState !is AuthState.Authenticated &&
networkStatusUiState.condition != NetworkCondition.Online
when (authState) {
is AuthState.Loading -> gateScreen = AppGateScreen.Loading.name
is AuthState.Loading -> {
if (allowOfflineProfileAccess) {
enterProfileGate(cachedProfiles, syncOnEnter = false)
} else {
gateScreen = AppGateScreen.Loading.name
}
}
is AuthState.Unauthenticated -> {
ProfileRepository.clearInMemory()
gateScreen = AppGateScreen.Auth.name
if (allowOfflineProfileAccess) {
enterProfileGate(cachedProfiles, syncOnEnter = false)
} else {
ProfileRepository.clearInMemory()
gateScreen = AppGateScreen.Auth.name
}
}
is AuthState.Authenticated -> {
val authenticatedState = authState as AuthState.Authenticated
ProfileRepository.ensureLoaded(authenticatedState.userId)
if (gateScreen == AppGateScreen.Loading.name || gateScreen == AppGateScreen.Auth.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
}
enterProfileGate(ProfileRepository.state.value.profiles, syncOnEnter = true)
}
}
}
}
LaunchedEffect((authState as? AuthState.Authenticated)?.userId) {
val authenticatedState = authState as? AuthState.Authenticated ?: return@LaunchedEffect
ProfileRepository.ensureLoaded(authenticatedState.userId)
ProfileRepository.pullProfiles()
}
LaunchedEffect(gateScreen, autoSkipProfileSelection, profileState.profiles) {
if (
autoSkipProfileSelection &&
@ -352,7 +397,9 @@ fun App() {
ProfileSelectionScreen(
onProfileSelected = { profile ->
ProfileRepository.selectProfile(profile.profileIndex)
SyncManager.pullAllForProfile(profile.profileIndex)
if (authState is AuthState.Authenticated) {
SyncManager.pullAllForProfile(profile.profileIndex)
}
gateScreen = AppGateScreen.Main.name
},
onEditProfile = { profile ->
@ -910,6 +957,9 @@ private fun MainAppContent(
}
},
onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) },
onSupportersContributorsSettingsClick = {
navController.navigate(SupportersContributorsSettingsRoute)
},
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
onFolderClick = { collectionId, folderId ->
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
@ -1515,6 +1565,15 @@ private fun MainAppContent(
onBack = onBack,
)
}
composable<SupportersContributorsSettingsRoute> { backStackEntry ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
backStackEntry = backStackEntry,
)
SupportersContributorsSettingsScreen(
onBack = onBack,
)
}
composable<CollectionsRoute> { backStackEntry ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
@ -1780,6 +1839,7 @@ private fun AppTabHost(
onAddonsSettingsClick: () -> Unit = {},
onPluginsSettingsClick: () -> Unit = {},
onAccountSettingsClick: () -> Unit = {},
onSupportersContributorsSettingsClick: () -> Unit = {},
onCollectionsSettingsClick: () -> Unit = {},
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
onInitialHomeContentRendered: () -> Unit = {},
@ -1829,6 +1889,7 @@ private fun AppTabHost(
onAddonsClick = onAddonsSettingsClick,
onPluginsClick = onPluginsSettingsClick,
onAccountClick = onAccountSettingsClick,
onSupportersContributorsClick = onSupportersContributorsSettingsClick,
onCollectionsClick = onCollectionsSettingsClick,
)
}

View file

@ -21,6 +21,26 @@ internal fun MetaDetails.sortedPlayableEpisodes(): List<MetaVideo> =
.filter { it.season != null || it.episode != null }
.sortedWith(metaVideoSeasonEpisodeComparator)
internal fun List<MetaVideo>.filterUnavailableFutureSeasons(
todayIsoDate: String,
): List<MetaVideo> {
val unavailableSeasons = groupBy { episode -> normalizeSeasonNumber(episode.season) }
.filter { (seasonNumber, episodes) ->
if (seasonNumber <= 0) return@filter false
val firstEpisode = episodes.minWithOrNull(
compareBy<MetaVideo>({ it.episode ?: Int.MAX_VALUE }, { it.released.orEmpty() }),
) ?: return@filter false
!isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = firstEpisode.released)
}
.keys
return if (unavailableSeasons.isEmpty()) {
this
} else {
filter { episode -> normalizeSeasonNumber(episode.season) !in unavailableSeasons }
}
}
internal fun MetaDetails.firstPlayableEpisode(): MetaVideo? =
sortedPlayableEpisodes().firstOrNull()

View file

@ -20,6 +20,7 @@ import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.filterUnavailableFutureSeasons
import com.nuvio.app.features.details.sortedPlayableEpisodes
import com.nuvio.app.features.home.components.HomeCatalogRowSection
import com.nuvio.app.features.home.components.HomeContinueWatchingSection
@ -568,6 +569,7 @@ internal fun buildHomeContinueWatchingItems(
compareByDescending<HomeContinueWatchingCandidate> { it.lastUpdatedEpochMs }
.thenByDescending { it.isProgressEntry },
)
.filter { candidate -> candidate.item.shouldDisplayInContinueWatching() }
.distinctBy { it.item.videoId }
.map(HomeContinueWatchingCandidate::item)
}
@ -627,6 +629,7 @@ private fun com.nuvio.app.features.details.MetaDetails.nextReleasedEpisodeAfter(
}
.drop(1)
.filter { episode -> (episode.season ?: 0) > 0 }
.filterUnavailableFutureSeasons(todayIsoDate = todayIsoDate)
if (showUnairedNextUp) {
return ordered.firstOrNull()
@ -637,6 +640,9 @@ private fun com.nuvio.app.features.details.MetaDetails.nextReleasedEpisodeAfter(
}
}
private fun ContinueWatchingItem.shouldDisplayInContinueWatching(): Boolean =
isNextUp || progressFraction < 0.995f
private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
val subtitle = buildString {
append("Up Next")

View file

@ -44,6 +44,9 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
import kotlin.math.roundToInt
private fun continueWatchingProgressPercent(progressFraction: Float): Int =
(progressFraction * 100f).roundToInt().coerceIn(1, 99)
@Composable
internal fun HomeContinueWatchingSection(
items: List<ContinueWatchingItem>,
@ -361,7 +364,7 @@ private fun ContinueWatchingWideCard(
trackColor = Color.White.copy(alpha = 0.10f),
)
Text(
text = "${(item.progressFraction * 100).roundToInt()}% watched",
text = "${continueWatchingProgressPercent(item.progressFraction)}% watched",
style = MaterialTheme.typography.labelSmall.copy(
fontSize = layout.progressLabelSize,
fontWeight = FontWeight.Medium,

View file

@ -7,34 +7,78 @@ import io.github.jan.supabase.postgrest.rpc
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
private data class StoredAvatarCatalogPayload(
val items: List<AvatarCatalogItem> = emptyList(),
)
object AvatarRepository {
private val log = Logger.withTag("AvatarRepository")
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val _avatars = MutableStateFlow<List<AvatarCatalogItem>>(emptyList())
val avatars: StateFlow<List<AvatarCatalogItem>> = _avatars.asStateFlow()
private var loaded = false
private var cacheHydrated = false
private var fetchInFlight = false
suspend fun fetchAvatars() {
hydrateFromCacheIfNeeded()
if (loaded && _avatars.value.isNotEmpty()) return
doFetch()
}
suspend fun refreshAvatars() {
hydrateFromCacheIfNeeded()
doFetch()
}
private fun hydrateFromCacheIfNeeded() {
if (cacheHydrated) return
cacheHydrated = true
val payload = AvatarStorage.loadPayload().orEmpty().trim()
if (payload.isEmpty()) return
val stored = runCatching {
json.decodeFromString<StoredAvatarCatalogPayload>(payload)
}.getOrNull() ?: return
val items = stored.items
.filter { it.isActive }
.sortedWith(compareBy({ it.category }, { it.sortOrder }))
if (items.isEmpty()) return
_avatars.value = items
loaded = true
}
private suspend fun doFetch() {
if (fetchInFlight) return
fetchInFlight = true
runCatching {
val result = SupabaseProvider.client.postgrest.rpc("get_avatar_catalog")
val items = result.decodeList<AvatarCatalogItem>()
_avatars.value = items.filter { it.isActive }.sortedWith(
val activeItems = items.filter { it.isActive }.sortedWith(
compareBy({ it.category }, { it.sortOrder }),
)
_avatars.value = activeItems
loaded = true
AvatarStorage.savePayload(
json.encodeToString(
StoredAvatarCatalogPayload(items = activeItems),
),
)
}.onFailure { e ->
log.e(e) { "Failed to fetch avatar catalog" }
}.also {
fetchInFlight = false
}
}
}

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.profiles
internal expect object AvatarStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
}

View file

@ -46,6 +46,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.ui.NuvioInputField
import com.nuvio.app.core.ui.NuvioPrimaryButton
import com.nuvio.app.core.ui.NuvioScreen
@ -79,9 +81,13 @@ fun ProfileEditScreen(
var showDeleteConfirm by remember { mutableStateOf(false) }
var showPinSetup by remember { mutableStateOf(false) }
var showPinClear by remember { mutableStateOf(false) }
val authState by AuthRepository.state.collectAsStateWithLifecycle()
val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { AvatarRepository.fetchAvatars() }
LaunchedEffect(Unit) {
AvatarRepository.fetchAvatars()
AvatarRepository.refreshAvatars()
}
LaunchedEffect(isNew, avatars, selectedAvatarId) {
if (isNew && selectedAvatarId == null && avatars.isNotEmpty()) {
selectedAvatarId = avatars.first().id
@ -272,7 +278,11 @@ fun ProfileEditScreen(
hasExistingPin = currentProfile.pinEnabled,
onDone = {
showPinSetup = false
scope.launch { ProfileRepository.pullProfiles() }
scope.launch {
if (authState is AuthState.Authenticated) {
ProfileRepository.pullProfiles()
}
}
},
onDismiss = { showPinSetup = false },
)

View file

@ -0,0 +1,20 @@
package com.nuvio.app.features.profiles
import kotlin.random.Random
import kotlinx.serialization.Serializable
@Serializable
internal data class CachedProfilePinPayload(
val salt: String,
val digest: String,
val profileUpdatedAt: String = "",
)
internal fun generateProfilePinSalt(): String {
val first = Random.nextLong().toULong().toString(16)
val second = Random.nextLong().toULong().toString(16)
return "$first$second"
}
internal fun hashProfilePin(profileIndex: Int, salt: String, pin: String): String =
ProfilePinCrypto.sha256Hex("profile:$profileIndex:$salt:$pin")

View file

@ -0,0 +1,7 @@
package com.nuvio.app.features.profiles
internal expect object ProfilePinCacheStorage {
fun loadPayload(profileIndex: Int): String?
fun savePayload(profileIndex: Int, payload: String)
fun removePayload(profileIndex: Int)
}

View file

@ -0,0 +1,5 @@
package com.nuvio.app.features.profiles
internal expect object ProfilePinCrypto {
fun sha256Hex(value: String): String
}

View file

@ -60,35 +60,31 @@ object ProfileRepository {
val activeProfileId: Int get() = activeProfileIndex
fun loadCachedProfiles(): Boolean {
val stored = decodeStoredPayload() ?: return false
loadedCacheForUserId = stored.userId
applyStoredPayload(stored)
return _state.value.profiles.isNotEmpty()
}
fun ensureLoaded(userId: String) {
if (loadedCacheForUserId == userId && _state.value.isLoaded) return
val stored = decodeStoredPayload()
loadedCacheForUserId = userId
val payload = ProfileStorage.loadPayload().orEmpty().trim()
if (payload.isEmpty()) {
if (stored == null) {
_state.value = ProfileState()
activeProfileIndex = 1
return
}
val stored = runCatching {
json.decodeFromString<StoredProfilePayload>(payload)
}.getOrNull() ?: return
if (stored.userId != userId) {
_state.value = ProfileState()
activeProfileIndex = 1
return
}
val profiles = stored.profiles.sortedBy { it.profileIndex }
activeProfileIndex = stored.activeProfileIndex
_state.value = ProfileState(
profiles = profiles,
activeProfile = profiles.find { it.profileIndex == activeProfileIndex } ?: profiles.firstOrNull(),
isLoaded = profiles.isNotEmpty(),
)
_state.value.activeProfile?.let { activeProfileIndex = it.profileIndex }
applyStoredPayload(stored)
}
fun clearInMemory() {
@ -232,6 +228,7 @@ object ProfileRepository {
suspend fun deleteProfile(profileIndex: Int) {
if (AuthRepository.state.value.isAnonymous) {
val remaining = _state.value.profiles.filter { it.profileIndex != profileIndex }
ProfilePinCacheStorage.removePayload(profileIndex)
_state.value = _state.value.copy(
profiles = remaining,
activeProfile = if (_state.value.activeProfile?.profileIndex == profileIndex) remaining.firstOrNull() else _state.value.activeProfile,
@ -252,20 +249,32 @@ object ProfileRepository {
}
suspend fun verifyPin(profileIndex: Int, pin: String): PinVerifyResult {
if (AuthRepository.state.value !is AuthState.Authenticated) {
return verifyPinLocally(profileIndex, pin)
}
return runCatching {
val params = buildJsonObject {
put("p_profile_id", profileIndex)
put("p_pin", pin)
}
val result = SupabaseProvider.client.postgrest.rpc("verify_profile_pin", params)
result.decodeSingle<PinVerifyResult>()
result.decodeSingle<PinVerifyResult>().also { verifyResult ->
if (verifyResult.unlocked) {
rememberVerifiedPin(profileIndex = profileIndex, pin = pin)
}
}
}.getOrElse { e ->
log.e(e) { "Failed to verify pin" }
PinVerifyResult(unlocked = false, retryAfterSeconds = 0, message = "Couldn't verify PIN. Try again.")
verifyPinLocally(profileIndex, pin)
}
}
suspend fun setPin(profileIndex: Int, pin: String, currentPin: String? = null): PinVerifyResult {
if (AuthRepository.state.value !is AuthState.Authenticated) {
return PinVerifyResult(unlocked = false, message = "Connect to the internet to set a PIN.")
}
return runCatching {
val params = buildJsonObject {
put("p_profile_id", profileIndex)
@ -274,6 +283,7 @@ object ProfileRepository {
}
SupabaseProvider.client.postgrest.rpc("set_profile_pin", params)
pullProfiles()
rememberVerifiedPin(profileIndex = profileIndex, pin = pin)
PinVerifyResult(unlocked = true)
}.onFailure { e ->
log.e(e) { "Failed to set pin" }
@ -283,6 +293,10 @@ object ProfileRepository {
}
suspend fun clearPin(profileIndex: Int, currentPin: String? = null): PinVerifyResult {
if (AuthRepository.state.value !is AuthState.Authenticated) {
return PinVerifyResult(unlocked = false, message = "Connect to the internet to remove the PIN lock.")
}
return runCatching {
val params = buildJsonObject {
put("p_profile_id", profileIndex)
@ -290,6 +304,7 @@ object ProfileRepository {
}
SupabaseProvider.client.postgrest.rpc("clear_profile_pin", params)
pullProfiles()
ProfilePinCacheStorage.removePayload(profileIndex)
PinVerifyResult(unlocked = true)
}.onFailure { e ->
log.e(e) { "Failed to clear pin" }
@ -306,6 +321,7 @@ object ProfileRepository {
}
SupabaseProvider.client.postgrest.rpc("clear_profile_pin_with_account_password", params)
pullProfiles()
ProfilePinCacheStorage.removePayload(profileIndex)
}.onFailure { e ->
log.e(e) { "Failed to clear pin with password" }
}
@ -343,9 +359,112 @@ object ProfileRepository {
if (_state.value.activeProfile != null) {
activeProfileIndex = _state.value.activeProfile!!.profileIndex
}
syncPinCache(profiles)
persist()
}
private fun decodeStoredPayload(): StoredProfilePayload? {
val payload = ProfileStorage.loadPayload().orEmpty().trim()
if (payload.isEmpty()) return null
return runCatching {
json.decodeFromString<StoredProfilePayload>(payload)
}.getOrNull()
}
private fun applyStoredPayload(stored: StoredProfilePayload) {
val profiles = stored.profiles.sortedBy { it.profileIndex }
activeProfileIndex = stored.activeProfileIndex
_state.value = ProfileState(
profiles = profiles,
activeProfile = profiles.find { it.profileIndex == activeProfileIndex } ?: profiles.firstOrNull(),
isLoaded = profiles.isNotEmpty(),
)
_state.value.activeProfile?.let { activeProfileIndex = it.profileIndex }
syncPinCache(profiles)
}
private fun rememberVerifiedPin(profileIndex: Int, pin: String) {
val profile = _state.value.profiles.find { it.profileIndex == profileIndex }
val salt = generateProfilePinSalt()
val payload = CachedProfilePinPayload(
salt = salt,
digest = hashProfilePin(profileIndex = profileIndex, salt = salt, pin = pin),
profileUpdatedAt = profile?.updatedAt.orEmpty(),
)
ProfilePinCacheStorage.savePayload(profileIndex, json.encodeToString(payload))
}
private fun verifyPinLocally(profileIndex: Int, pin: String): PinVerifyResult {
val profile = _state.value.profiles.find { it.profileIndex == profileIndex }
if (profile?.pinEnabled != true) {
return PinVerifyResult(unlocked = true)
}
val payload = ProfilePinCacheStorage.loadPayload(profileIndex).orEmpty().trim()
if (payload.isEmpty()) {
return PinVerifyResult(
unlocked = false,
message = "This PIN can't be verified offline on this device yet. Connect once and unlock it online first.",
)
}
val cached = runCatching {
json.decodeFromString<CachedProfilePinPayload>(payload)
}.getOrNull() ?: return PinVerifyResult(
unlocked = false,
message = "This PIN can't be verified offline on this device yet. Connect once and unlock it online first.",
)
if (
cached.profileUpdatedAt.isNotBlank() &&
profile.updatedAt.isNotBlank() &&
cached.profileUpdatedAt != profile.updatedAt
) {
ProfilePinCacheStorage.removePayload(profileIndex)
return PinVerifyResult(
unlocked = false,
message = "This profile PIN changed. Connect once to refresh the lock on this device.",
)
}
val digest = hashProfilePin(profileIndex = profileIndex, salt = cached.salt, pin = pin)
return if (digest == cached.digest) {
PinVerifyResult(unlocked = true)
} else {
PinVerifyResult(unlocked = false, message = "Incorrect PIN")
}
}
private fun syncPinCache(profiles: List<NuvioProfile>) {
val profilesByIndex = profiles.associateBy { it.profileIndex }
for (profileIndex in 1..4) {
val profile = profilesByIndex[profileIndex]
if (profile == null || !profile.pinEnabled) {
ProfilePinCacheStorage.removePayload(profileIndex)
continue
}
val raw = ProfilePinCacheStorage.loadPayload(profileIndex).orEmpty().trim()
if (raw.isEmpty()) continue
val cached = runCatching {
json.decodeFromString<CachedProfilePinPayload>(raw)
}.getOrNull() ?: run {
ProfilePinCacheStorage.removePayload(profileIndex)
continue
}
if (
cached.profileUpdatedAt.isNotBlank() &&
profile.updatedAt.isNotBlank() &&
cached.profileUpdatedAt != profile.updatedAt
) {
ProfilePinCacheStorage.removePayload(profileIndex)
}
}
}
private fun persist() {
val authState = AuthRepository.state.value as? AuthState.Authenticated ?: return
ProfileStorage.savePayload(

View file

@ -57,6 +57,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -67,6 +69,7 @@ fun ProfileSelectionScreen(
onAddProfile: () -> Unit,
modifier: Modifier = Modifier,
) {
val authState by AuthRepository.state.collectAsStateWithLifecycle()
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var pinDialogProfile by remember { mutableStateOf<NuvioProfile?>(null) }
@ -77,11 +80,16 @@ fun ProfileSelectionScreen(
val manageAlpha = remember { Animatable(0f) }
LaunchedEffect(Unit) {
ProfileRepository.pullProfiles()
AvatarRepository.fetchAvatars()
AvatarRepository.refreshAvatars()
}
LaunchedEffect(authState) {
if (authState is AuthState.Authenticated) {
ProfileRepository.pullProfiles()
}
}
LaunchedEffect(Unit) {
launch { titleAlpha.animateTo(1f, tween(600, easing = FastOutSlowInEasing)) }
launch { titleOffset.animateTo(0f, tween(600, easing = FastOutSlowInEasing)) }

View file

@ -83,6 +83,7 @@ fun ProfileSwitcherTab(
LaunchedEffect(Unit) {
AvatarRepository.fetchAvatars()
AvatarRepository.refreshAvatars()
}
val haptic = LocalHapticFeedback.current

View file

@ -40,7 +40,9 @@ internal fun LazyListScope.accountSettingsContent(
}
@Composable
private fun AccountSettingsBody(isTablet: Boolean) {
private fun AccountSettingsBody(
isTablet: Boolean,
) {
val authState by AuthRepository.state.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var showDeleteConfirm by remember { mutableStateOf(false) }

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AccountCircle
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.ui.graphics.vector.ImageVector
@ -12,6 +13,7 @@ internal enum class SettingsCategory(
) {
Account("Account", Icons.Rounded.AccountCircle),
General("General", Icons.Rounded.Settings),
About("About", Icons.Rounded.Info),
}
internal enum class SettingsPage(
@ -29,6 +31,11 @@ internal enum class SettingsPage(
category = SettingsCategory.Account,
parentPage = Root,
),
SupportersContributors(
title = "Supporters & Contributors",
category = SettingsCategory.About,
parentPage = Root,
),
Playback(
title = "Playback",
category = SettingsCategory.General,

View file

@ -7,6 +7,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AccountCircle
import androidx.compose.material.icons.rounded.CloudDownload
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Favorite
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Link
import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Palette
@ -27,11 +29,13 @@ internal fun LazyListScope.settingsRootContent(
onContentDiscoveryClick: () -> Unit,
onIntegrationsClick: () -> Unit,
onTraktClick: () -> Unit,
onSupportersContributorsClick: () -> Unit,
onDownloadsClick: () -> Unit,
onAccountClick: () -> Unit,
onSwitchProfileClick: (() -> Unit)? = null,
showAccountSection: Boolean = true,
showGeneralSection: Boolean = true,
showAboutSection: Boolean = true,
) {
if (showAccountSection) {
item {
@ -127,15 +131,44 @@ internal fun LazyListScope.settingsRootContent(
}
}
}
if (showAboutSection) {
item {
SettingsSection(
title = "ABOUT",
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsNavigationRow(
title = "Supporters & Contributors",
description = "See cross-app contributors and the supporters backing Nuvio.",
icon = Icons.Rounded.Favorite,
isTablet = isTablet,
onClick = onSupportersContributorsClick,
)
}
}
}
}
item {
Text(
text = "Version ${AppVersionConfig.VERSION_NAME} (${AppVersionConfig.VERSION_CODE})",
androidx.compose.foundation.layout.Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = if (isTablet) 20.dp else 16.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
) {
Text(
text = "Made with ❤️ by Tapframe and friends",
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Text(
text = "Version ${AppVersionConfig.VERSION_NAME} (${AppVersionConfig.VERSION_CODE})",
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}

View file

@ -70,6 +70,7 @@ fun SettingsScreen(
onPluginsClick: () -> Unit = {},
onDownloadsClick: () -> Unit = {},
onAccountClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {},
onCollectionsClick: () -> Unit = {},
) {
BoxWithConstraints(
@ -188,6 +189,7 @@ fun SettingsScreen(
posterCardStyleUiState = posterCardStyleUiState,
onSwitchProfile = onSwitchProfile,
onDownloadsClick = onDownloadsClick,
onSupportersContributorsClick = onSupportersContributorsClick,
onCollectionsClick = onCollectionsClick,
)
} else {
@ -230,6 +232,7 @@ fun SettingsScreen(
onPluginsClick = onPluginsClick,
onDownloadsClick = onDownloadsClick,
onAccountClick = onAccountClick,
onSupportersContributorsClick = onSupportersContributorsClick,
onCollectionsClick = onCollectionsClick,
)
}
@ -276,6 +279,7 @@ private fun MobileSettingsScreen(
onPluginsClick: () -> Unit = {},
onDownloadsClick: () -> Unit = {},
onAccountClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {},
onCollectionsClick: () -> Unit = {},
) {
NuvioScreen {
@ -296,6 +300,7 @@ private fun MobileSettingsScreen(
onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) },
onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = onSupportersContributorsClick,
onDownloadsClick = onDownloadsClick,
onAccountClick = onAccountClick,
onSwitchProfileClick = onSwitchProfile,
@ -303,6 +308,9 @@ private fun MobileSettingsScreen(
SettingsPage.Account -> accountSettingsContent(
isTablet = false,
)
SettingsPage.SupportersContributors -> supportersContributorsContent(
isTablet = false,
)
SettingsPage.Playback -> playbackSettingsContent(
isTablet = false,
showLoadingOverlay = showLoadingOverlay,
@ -421,6 +429,7 @@ private fun TabletSettingsScreen(
posterCardStyleUiState: PosterCardStyleUiState,
onSwitchProfile: (() -> Unit)? = null,
onDownloadsClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {},
onCollectionsClick: () -> Unit = {},
) {
var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) }
@ -508,15 +517,20 @@ private fun TabletSettingsScreen(
onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) },
onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
onDownloadsClick = onDownloadsClick,
onAccountClick = { openInlinePage(SettingsPage.Account) },
onSwitchProfileClick = onSwitchProfile,
showAccountSection = activeCategory == SettingsCategory.Account,
showGeneralSection = activeCategory == SettingsCategory.General,
showAboutSection = activeCategory == SettingsCategory.About,
)
SettingsPage.Account -> accountSettingsContent(
isTablet = true,
)
SettingsPage.SupportersContributors -> supportersContributorsContent(
isTablet = true,
)
SettingsPage.Playback -> playbackSettingsContent(
isTablet = true,
showLoadingOverlay = showLoadingOverlay,

View file

@ -0,0 +1,994 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
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.OpenInNew
import androidx.compose.material.icons.rounded.Favorite
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.NuvioSurfaceCard
import com.nuvio.app.features.addons.httpRequestRaw
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
private enum class CommunityTab {
Contributors,
Supporters,
}
private data class CommunityUiState(
val selectedTab: CommunityTab = CommunityTab.Contributors,
val isContributorsLoading: Boolean = false,
val hasLoadedContributors: Boolean = false,
val contributors: List<CommunityContributor> = emptyList(),
val contributorsErrorMessage: String? = null,
val isSupportersLoading: Boolean = false,
val hasLoadedSupporters: Boolean = false,
val supporters: List<SupporterDonation> = emptyList(),
val supportersErrorMessage: String? = null,
)
@Serializable
private data class GitHubContributorDto(
val login: String? = null,
@SerialName("avatar_url") val avatarUrl: String? = null,
@SerialName("html_url") val htmlUrl: String? = null,
val contributions: Int? = null,
val type: String? = null,
)
@Serializable
private data class DonationsResponseDto(
val donations: List<DonationDto> = emptyList(),
)
@Serializable
private data class DonationDto(
val name: String? = null,
val date: String? = null,
val message: String? = null,
)
internal data class CommunityContributor(
val login: String,
val avatarUrl: String?,
val profileUrl: String?,
val totalContributions: Int,
val mobileContributions: Int,
val tvContributions: Int,
val webContributions: Int,
)
internal data class SupporterDonation(
val key: String,
val name: String,
val date: String,
val message: String?,
val sortTimestamp: Long,
)
private object SupportersContributorsRepository {
private const val gitHubOwner = "nuviomedia"
private const val mobileRepository = "nuviomobile"
private const val tvRepository = "nuviotv"
private const val webRepository = "nuvioweb"
private const val gitHubApiBase = "https://api.github.com"
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
suspend fun getContributors(): Result<List<CommunityContributor>> = runCatching {
coroutineScope {
val mobileDeferred = async { fetchRepoContributors(mobileRepository) }
val tvDeferred = async { fetchRepoContributors(tvRepository) }
val webDeferred = async { fetchRepoContributors(webRepository) }
val mobileResult = mobileDeferred.await()
val tvResult = tvDeferred.await()
val webResult = webDeferred.await()
if (mobileResult.isFailure && tvResult.isFailure && webResult.isFailure) {
throw (
mobileResult.exceptionOrNull()
?: tvResult.exceptionOrNull()
?: webResult.exceptionOrNull()
?: IllegalStateException("Unable to load contributors")
)
}
mergeContributors(
mobileContributors = mobileResult.getOrDefault(emptyList()),
tvContributors = tvResult.getOrDefault(emptyList()),
webContributors = webResult.getOrDefault(emptyList()),
)
}
}
suspend fun getSupporters(limit: Int = 200): Result<List<SupporterDonation>> = runCatching {
val baseUrl = CommunityConfig.DONATIONS_BASE_URL.trim().removeSuffix("/")
check(baseUrl.isNotBlank()) {
"Supporters endpoint is not configured. Add DONATIONS_BASE_URL to local.properties."
}
val response = httpRequestRaw(
method = "GET",
url = "$baseUrl/api/donations?limit=$limit",
headers = emptyMap(),
body = "",
)
if (response.status !in 200..299) {
error("Donations API error: ${response.status}")
}
json.decodeFromString<DonationsResponseDto>(response.body)
.donations
.mapNotNull { donation ->
val name = donation.name?.trim().orEmpty()
val date = donation.date?.trim().orEmpty()
if (name.isBlank() || date.isBlank()) return@mapNotNull null
SupporterDonation(
key = "${name.lowercase()}-$date",
name = name,
date = date,
message = donation.message?.trim()?.takeIf { it.isNotBlank() },
sortTimestamp = supporterSortTimestamp(date),
)
}
.sortedByDescending { it.sortTimestamp }
.mapIndexed { index, donation ->
donation.copy(key = "${donation.key}#$index")
}
}
private suspend fun fetchRepoContributors(repo: String): Result<List<GitHubContributorDto>> = runCatching {
val contributors = mutableListOf<GitHubContributorDto>()
var nextUrl: String? = "$gitHubApiBase/repos/$gitHubOwner/$repo/contributors?per_page=100"
while (nextUrl != null) {
val response = httpRequestRaw(
method = "GET",
url = nextUrl,
headers = mapOf(
"Accept" to "application/vnd.github+json",
"User-Agent" to "NuvioMobile",
),
body = "",
)
if (response.status !in 200..299) {
error("GitHub contributors API error for $repo: ${response.status}")
}
contributors += json.decodeFromString<List<GitHubContributorDto>>(response.body)
nextUrl = response.headers.entries
.firstOrNull { it.key.equals("link", ignoreCase = true) }
?.value
?.let(::parseNextLink)
}
contributors
}
private fun mergeContributors(
mobileContributors: List<GitHubContributorDto>,
tvContributors: List<GitHubContributorDto>,
webContributors: List<GitHubContributorDto>,
): List<CommunityContributor> {
val contributorsByLogin = linkedMapOf<String, MutableCommunityContributor>()
mobileContributors.forEach { dto ->
normalizeContributor(dto)?.let { contributor ->
val entry = contributorsByLogin.getOrPut(contributor.login.lowercase()) {
MutableCommunityContributor(
login = contributor.login,
avatarUrl = contributor.avatarUrl,
profileUrl = contributor.htmlUrl,
)
}
entry.avatarUrl = entry.avatarUrl ?: contributor.avatarUrl
entry.profileUrl = entry.profileUrl ?: contributor.htmlUrl
entry.mobileContributions += contributor.contributions
}
}
tvContributors.forEach { dto ->
normalizeContributor(dto)?.let { contributor ->
val entry = contributorsByLogin.getOrPut(contributor.login.lowercase()) {
MutableCommunityContributor(
login = contributor.login,
avatarUrl = contributor.avatarUrl,
profileUrl = contributor.htmlUrl,
)
}
entry.avatarUrl = entry.avatarUrl ?: contributor.avatarUrl
entry.profileUrl = entry.profileUrl ?: contributor.htmlUrl
entry.tvContributions += contributor.contributions
}
}
webContributors.forEach { dto ->
normalizeContributor(dto)?.let { contributor ->
val entry = contributorsByLogin.getOrPut(contributor.login.lowercase()) {
MutableCommunityContributor(
login = contributor.login,
avatarUrl = contributor.avatarUrl,
profileUrl = contributor.htmlUrl,
)
}
entry.avatarUrl = entry.avatarUrl ?: contributor.avatarUrl
entry.profileUrl = entry.profileUrl ?: contributor.htmlUrl
entry.webContributions += contributor.contributions
}
}
return contributorsByLogin.values
.map { contributor ->
CommunityContributor(
login = contributor.login,
avatarUrl = contributor.avatarUrl,
profileUrl = contributor.profileUrl,
totalContributions = contributor.mobileContributions + contributor.tvContributions + contributor.webContributions,
mobileContributions = contributor.mobileContributions,
tvContributions = contributor.tvContributions,
webContributions = contributor.webContributions,
)
}
.sortedWith(
compareByDescending<CommunityContributor> { it.totalContributions }
.thenBy { it.login.lowercase() },
)
}
private fun normalizeContributor(dto: GitHubContributorDto): NormalizedContributor? {
val login = dto.login?.trim().orEmpty()
val contributions = dto.contributions ?: 0
val type = dto.type?.trim()
if (login.isBlank() || contributions <= 0) return null
if (type != null && !type.equals("User", ignoreCase = true)) return null
return NormalizedContributor(
login = login,
avatarUrl = dto.avatarUrl?.trim()?.takeIf { it.isNotBlank() },
htmlUrl = dto.htmlUrl?.trim()?.takeIf { it.isNotBlank() },
contributions = contributions,
)
}
private fun parseNextLink(linkHeader: String): String? =
linkHeader.split(',')
.map(String::trim)
.firstOrNull { it.contains("rel=\"next\"") }
?.substringAfter('<')
?.substringBefore('>')
?.takeIf { it.isNotBlank() }
private fun supporterSortTimestamp(rawDate: String): Long {
val datePart = rawDate.substringBefore('T')
val parts = datePart.split('-')
if (parts.size != 3) return Long.MIN_VALUE
val year = parts[0].toLongOrNull() ?: return Long.MIN_VALUE
val month = parts[1].toLongOrNull() ?: return Long.MIN_VALUE
val day = parts[2].toLongOrNull() ?: return Long.MIN_VALUE
return year * 10_000L + month * 100L + day
}
private data class NormalizedContributor(
val login: String,
val avatarUrl: String?,
val htmlUrl: String?,
val contributions: Int,
)
private data class MutableCommunityContributor(
val login: String,
var avatarUrl: String?,
var profileUrl: String?,
var mobileContributions: Int = 0,
var tvContributions: Int = 0,
var webContributions: Int = 0,
)
}
@Composable
fun SupportersContributorsSettingsScreen(
onBack: () -> Unit,
) {
NuvioScreen(
modifier = Modifier.fillMaxSize(),
) {
stickyHeader {
NuvioScreenHeader(
title = "Supporters & Contributors",
onBack = onBack,
)
}
supportersContributorsContent(isTablet = false)
}
}
internal fun LazyListScope.supportersContributorsContent(
isTablet: Boolean,
) {
item {
SupportersContributorsBody(isTablet = isTablet)
}
}
@Composable
private fun SupportersContributorsBody(
isTablet: Boolean,
) {
val uriHandler = LocalUriHandler.current
val scope = rememberCoroutineScope()
val donateUrl = remember { CommunityConfig.DONATIONS_DONATE_URL.trim().removeSuffix("/") }
val donationsConfigured = remember { CommunityConfig.DONATIONS_BASE_URL.trim().isNotBlank() }
val donateConfigured = donateUrl.isNotBlank()
var uiState by remember { mutableStateOf(CommunityUiState()) }
var selectedContributor by remember { mutableStateOf<CommunityContributor?>(null) }
var selectedSupporter by remember { mutableStateOf<SupporterDonation?>(null) }
fun loadContributors(force: Boolean) {
if (uiState.isContributorsLoading) return
if (!force && uiState.hasLoadedContributors) return
scope.launch {
uiState = uiState.copy(
isContributorsLoading = true,
contributorsErrorMessage = null,
)
SupportersContributorsRepository.getContributors()
.onSuccess { contributors ->
uiState = uiState.copy(
isContributorsLoading = false,
hasLoadedContributors = true,
contributors = contributors,
contributorsErrorMessage = null,
)
}
.onFailure { error ->
uiState = uiState.copy(
isContributorsLoading = false,
hasLoadedContributors = false,
contributors = emptyList(),
contributorsErrorMessage = error.message ?: "Unable to load contributors.",
)
}
}
}
fun loadSupporters(force: Boolean) {
if (uiState.isSupportersLoading) return
if (!force && uiState.hasLoadedSupporters) return
scope.launch {
uiState = uiState.copy(
isSupportersLoading = true,
supportersErrorMessage = null,
)
SupportersContributorsRepository.getSupporters()
.onSuccess { supporters ->
uiState = uiState.copy(
isSupportersLoading = false,
hasLoadedSupporters = true,
supporters = supporters,
supportersErrorMessage = null,
)
}
.onFailure { error ->
uiState = uiState.copy(
isSupportersLoading = false,
hasLoadedSupporters = false,
supporters = emptyList(),
supportersErrorMessage = error.message ?: "Unable to load supporters.",
)
}
}
}
LaunchedEffect(Unit) {
loadContributors(force = false)
}
LaunchedEffect(uiState.selectedTab) {
if (uiState.selectedTab == CommunityTab.Supporters) {
loadSupporters(force = false)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(if (isTablet) 18.dp else 14.dp),
) {
NuvioSurfaceCard {
Text(
text = "Community",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "See the people building and supporting Nuvio across Mobile, TV, and Web.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { if (donateConfigured) uriHandler.openUri(donateUrl) },
enabled = donateConfigured,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Rounded.Favorite,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.size(8.dp))
Text("Donate")
}
if (!donationsConfigured) {
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "Supporters API is not configured. Add DONATIONS_BASE_URL to local.properties.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
NuvioSurfaceCard {
CommunityTabRow(
selectedTab = uiState.selectedTab,
onSelectTab = { tab -> uiState = uiState.copy(selectedTab = tab) },
)
}
when (uiState.selectedTab) {
CommunityTab.Contributors -> ContributorsCard(
contributors = uiState.contributors,
isLoading = uiState.isContributorsLoading,
errorMessage = uiState.contributorsErrorMessage,
onRetry = { loadContributors(force = true) },
onContributorClick = { selectedContributor = it },
)
CommunityTab.Supporters -> SupportersCard(
supporters = uiState.supporters,
isLoading = uiState.isSupportersLoading,
errorMessage = uiState.supportersErrorMessage,
onRetry = { loadSupporters(force = true) },
onSupporterClick = { selectedSupporter = it },
)
}
}
selectedContributor?.let { contributor ->
val supportUrl = contributorSupportLink(contributor.login)
CommunityDetailsDialog(
title = contributor.login,
subtitle = contributorContributionSummary(contributor),
onDismiss = { selectedContributor = null },
primaryActionLabel = if (contributor.profileUrl != null) "Open GitHub" else null,
onPrimaryAction = contributor.profileUrl?.let { url -> { uriHandler.openUri(url) } },
secondaryActionLabel = if (supportUrl != null) "Donate" else null,
onSecondaryAction = supportUrl?.let { url -> { uriHandler.openUri(url) } },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
CommunityAvatar(
label = contributor.login,
imageUrl = contributor.avatarUrl,
modifier = Modifier.size(72.dp),
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = contributorContributionSummary(contributor),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = contributor.profileUrl ?: "GitHub profile unavailable",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
selectedSupporter?.let { supporter ->
CommunityDetailsDialog(
title = supporter.name,
subtitle = formatDonationDate(supporter.date),
onDismiss = { selectedSupporter = null },
primaryActionLabel = null,
onPrimaryAction = null,
secondaryActionLabel = null,
onSecondaryAction = null,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
NameAvatar(
label = supporter.name,
modifier = Modifier.size(72.dp),
)
Text(
text = supporter.message ?: "No message attached.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun CommunityTabRow(
selectedTab: CommunityTab,
onSelectTab: (CommunityTab) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
CommunityTab.entries.forEach { tab ->
val isSelected = tab == selectedTab
Surface(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(999.dp))
.clickable { onSelectTab(tab) },
color = if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f)
},
shape = RoundedCornerShape(999.dp),
) {
Box(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = if (tab == CommunityTab.Contributors) "Contributors" else "Supporters",
style = MaterialTheme.typography.bodyLarge,
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
)
}
}
}
}
}
@Composable
private fun ContributorsCard(
contributors: List<CommunityContributor>,
isLoading: Boolean,
errorMessage: String?,
onRetry: () -> Unit,
onContributorClick: (CommunityContributor) -> Unit,
) {
NuvioSurfaceCard {
when {
isLoading -> LoadingState(label = "Loading contributors...")
errorMessage != null -> ErrorState(
title = "Couldn't load contributors",
message = errorMessage,
onRetry = onRetry,
)
contributors.isEmpty() -> EmptyState(label = "No contributors found.")
else -> LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 480.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
contentPadding = PaddingValues(vertical = 2.dp),
) {
items(
items = contributors,
key = { contributor -> contributor.login.lowercase() },
) { contributor ->
ContributorRow(
contributor = contributor,
onClick = { onContributorClick(contributor) },
)
}
}
}
}
}
@Composable
private fun SupportersCard(
supporters: List<SupporterDonation>,
isLoading: Boolean,
errorMessage: String?,
onRetry: () -> Unit,
onSupporterClick: (SupporterDonation) -> Unit,
) {
NuvioSurfaceCard {
when {
isLoading -> LoadingState(label = "Loading supporters...")
errorMessage != null -> ErrorState(
title = "Couldn't load supporters",
message = errorMessage,
onRetry = onRetry,
)
supporters.isEmpty() -> EmptyState(label = "No supporters found.")
else -> LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 480.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
contentPadding = PaddingValues(vertical = 2.dp),
) {
items(
items = supporters,
key = { supporter -> supporter.key },
) { supporter ->
SupporterRow(
supporter = supporter,
onClick = { onSupporterClick(supporter) },
)
}
}
}
}
}
@Composable
private fun ContributorRow(
contributor: CommunityContributor,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
CommunityAvatar(
label = contributor.login,
imageUrl = contributor.avatarUrl,
modifier = Modifier.size(54.dp),
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = contributor.login,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = contributorContributionSummary(contributor),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Icon(
imageVector = Icons.AutoMirrored.Rounded.OpenInNew,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun SupporterRow(
supporter: SupporterDonation,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
NameAvatar(
label = supporter.name,
modifier = Modifier.size(54.dp),
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = supporter.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = formatDonationDate(supporter.date),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
supporter.message?.let { message ->
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
Icon(
imageVector = Icons.AutoMirrored.Rounded.OpenInNew,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun CommunityAvatar(
label: String,
imageUrl: String?,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (imageUrl.isNullOrBlank()) {
Text(
text = label.take(1).uppercase(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
} else {
AsyncImage(
model = imageUrl,
contentDescription = label,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
}
}
@Composable
private fun NameAvatar(
label: String,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Text(
text = label.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
}
}
@Composable
private fun LoadingState(
label: String,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(strokeWidth = 2.dp)
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun EmptyState(
label: String,
) {
Text(
text = label,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
@Composable
private fun ErrorState(
title: String,
message: String,
onRetry: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Button(onClick = onRetry) {
Text("Retry")
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
private fun CommunityDetailsDialog(
title: String,
subtitle: String,
onDismiss: () -> Unit,
primaryActionLabel: String?,
onPrimaryAction: (() -> Unit)?,
secondaryActionLabel: String?,
onSecondaryAction: (() -> Unit)?,
content: @Composable () -> Unit,
) {
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(24.dp),
) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
content()
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (primaryActionLabel != null && onPrimaryAction != null) {
Button(onClick = onPrimaryAction) {
Text(primaryActionLabel)
}
}
if (secondaryActionLabel != null && onSecondaryAction != null) {
Button(onClick = onSecondaryAction) {
Text(secondaryActionLabel)
}
}
}
}
}
}
}
private fun contributorContributionSummary(contributor: CommunityContributor): String =
"${contributor.totalContributions} total commits"
private fun contributorSupportLink(login: String): String? = when (login.lowercase()) {
"skoruppa" -> "https://ko-fi.com/skoruppa"
"crisszollo", "xrissozollo" -> "https://ko-fi.com/crisszollo"
else -> null
}
private fun formatDonationDate(rawDate: String): String {
val datePart = rawDate.substringBefore('T')
val parts = datePart.split('-')
if (parts.size != 3) return rawDate
val year = parts[0]
val month = parts[1].toIntOrNull()?.let { monthIndex ->
listOf(
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
).getOrNull(monthIndex - 1)
} ?: return rawDate
val day = parts[2].toIntOrNull()?.toString() ?: return rawDate
return "$month $day, $year"
}

View file

@ -745,8 +745,9 @@ internal fun StreamList(
}
else -> {
filteredGroups.forEach { group ->
filteredGroups.forEachIndexed { groupIndex, group ->
streamSection(
sectionKey = streamSectionRenderKey(groupIndex = groupIndex, group = group),
group = group,
showHeader = uiState.selectedFilter == null,
onStreamSelected = onStreamSelected,
@ -769,6 +770,7 @@ internal fun StreamList(
}
private fun LazyListScope.streamSection(
sectionKey: String,
group: AddonStreamGroup,
showHeader: Boolean,
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
@ -779,7 +781,7 @@ private fun LazyListScope.streamSection(
if (group.streams.isEmpty() && !group.isLoading) return
if (showHeader) {
item(key = "header_${group.addonId}") {
item(key = "header_$sectionKey") {
StreamSectionHeader(
addonName = group.addonName,
isLoading = group.isLoading,
@ -793,10 +795,10 @@ private fun LazyListScope.streamSection(
val sortedSources = streamsBySource.keys.sortedBy { it.lowercase() }
val showSourceHeaders = sortedSources.size > 1
sortedSources.forEach { sourceName ->
sortedSources.forEachIndexed { sourceIndex, sourceName ->
val sourceStreams = streamsBySource[sourceName].orEmpty()
if (showSourceHeaders) {
item(key = "source_${group.addonId}_${sourceName}") {
item(key = "source_${sectionKey}_$sourceIndex") {
StreamSourceHeader(sourceName = sourceName)
}
}
@ -804,7 +806,12 @@ private fun LazyListScope.streamSection(
itemsIndexed(
items = sourceStreams,
key = { index, stream ->
"${group.addonId}_${sourceName}_${index}_${stream.url ?: stream.infoHash ?: stream.streamLabel}"
streamCardRenderKey(
sectionKey = sectionKey,
sourceIndex = sourceIndex,
itemIndex = index,
stream = stream,
)
},
) { _, stream ->
StreamCard(
@ -825,6 +832,26 @@ private fun LazyListScope.streamSection(
}
}
internal fun streamSectionRenderKey(
groupIndex: Int,
group: AddonStreamGroup,
): String = "$groupIndex:${group.addonId}"
internal fun streamCardRenderKey(
sectionKey: String,
sourceIndex: Int,
itemIndex: Int,
stream: StreamItem,
): String = buildString {
append(sectionKey)
append(':')
append(sourceIndex)
append(':')
append(itemIndex)
append(':')
append(stream.url ?: stream.infoHash ?: stream.streamLabel)
}
// ---------------------------------------------------------------------------
// Stream Section Header
// ---------------------------------------------------------------------------

View file

@ -3,7 +3,11 @@ package com.nuvio.app.core.storage
import platform.Foundation.NSUserDefaults
internal actual object PlatformLocalAccountDataCleaner {
private val plainKeys = listOf("profile_payload")
private val plainKeys = listOf(
"profile_payload",
"avatar_catalog_payload",
)
private val profilePinCachePrefixes = listOf("profile_pin_cache_")
private val profileIndexedPrefixes = listOf(
"installed_manifest_urls_",
"plugins_state_",
@ -52,6 +56,9 @@ internal actual object PlatformLocalAccountDataCleaner {
profileIndexedPrefixes.forEach { prefix ->
defaults.removeObjectForKey("$prefix$profileId")
}
profilePinCachePrefixes.forEach { prefix ->
defaults.removeObjectForKey("$prefix$profileId")
}
profileScopedBaseKeys.forEach { baseKey ->
defaults.removeObjectForKey("${baseKey}_$profileId")
}

View file

@ -0,0 +1,14 @@
package com.nuvio.app.features.profiles
import platform.Foundation.NSUserDefaults
actual object AvatarStorage {
private const val payloadKey = "avatar_catalog_payload"
actual fun loadPayload(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(payloadKey)
actual fun savePayload(payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = payloadKey)
}
}

View file

@ -0,0 +1,18 @@
package com.nuvio.app.features.profiles
import platform.Foundation.NSUserDefaults
actual object ProfilePinCacheStorage {
actual fun loadPayload(profileIndex: Int): String? =
NSUserDefaults.standardUserDefaults.stringForKey(payloadKey(profileIndex))
actual fun savePayload(profileIndex: Int, payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = payloadKey(profileIndex))
}
actual fun removePayload(profileIndex: Int) {
NSUserDefaults.standardUserDefaults.removeObjectForKey(payloadKey(profileIndex))
}
private fun payloadKey(profileIndex: Int): String = "profile_pin_cache_$profileIndex"
}

View file

@ -0,0 +1,18 @@
package com.nuvio.app.features.profiles
import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256
import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256_DIGEST_LENGTH
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.refTo
actual object ProfilePinCrypto {
@OptIn(ExperimentalForeignApi::class)
actual fun sha256Hex(value: String): String {
val input = value.encodeToByteArray()
val output = UByteArray(CC_SHA256_DIGEST_LENGTH.toInt())
CC_SHA256(input.refTo(0), input.size.toUInt(), output.refTo(0))
return output.joinToString(separator = "") { byte ->
byte.toString(16).padStart(2, '0')
}
}
}

1
mediamp Submodule

@ -0,0 +1 @@
Subproject commit df33966d7fbc6eb14e43fb1892e062417d76e7f5