Merge branch 'cmp-rewrite' into feat/disable-swipe-gestures

This commit is contained in:
Sai Mukesh Cheekatla 2026-05-09 21:58:29 +05:30 committed by GitHub
commit e93ffa02c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1757 additions and 170 deletions

View file

@ -13,6 +13,7 @@ import com.nuvio.app.core.auth.AuthStorage
import com.nuvio.app.core.deeplink.handleAppUrl import com.nuvio.app.core.deeplink.handleAppUrl
import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
import com.nuvio.app.features.addons.AddonStorage import com.nuvio.app.features.addons.AddonStorage
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
import com.nuvio.app.features.collection.CollectionStorage import com.nuvio.app.features.collection.CollectionStorage
import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform
import com.nuvio.app.features.downloads.DownloadsPlatformDownloader import com.nuvio.app.features.downloads.DownloadsPlatformDownloader
@ -83,6 +84,7 @@ class MainActivity : AppCompatActivity() {
WatchProgressStorage.initialize(applicationContext) WatchProgressStorage.initialize(applicationContext)
StreamLinkCacheStorage.initialize(applicationContext) StreamLinkCacheStorage.initialize(applicationContext)
PluginStorage.initialize(applicationContext) PluginStorage.initialize(applicationContext)
CollectionMobileSettingsStorage.initialize(applicationContext)
CollectionStorage.initialize(applicationContext) CollectionStorage.initialize(applicationContext)
DownloadsStorage.initialize(applicationContext) DownloadsStorage.initialize(applicationContext)
DownloadsPlatformDownloader.initialize(applicationContext) DownloadsPlatformDownloader.initialize(applicationContext)

View file

@ -23,6 +23,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_episode_release_notifications", "nuvio_episode_release_notifications",
"nuvio_episode_release_notifications_platform", "nuvio_episode_release_notifications_platform",
"nuvio_watch_progress", "nuvio_watch_progress",
"nuvio_collection_mobile_settings",
"nuvio_collections", "nuvio_collections",
"nuvio_plugins", "nuvio_plugins",
) )

View file

@ -0,0 +1,26 @@
package com.nuvio.app.features.collection
import android.content.Context
import android.content.SharedPreferences
import com.nuvio.app.core.storage.ProfileScopedKey
actual object CollectionMobileSettingsStorage {
private const val preferencesName = "nuvio_collection_mobile_settings"
private const val payloadKey = "collection_mobile_settings_payload"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun loadPayload(): String? =
preferences?.getString(ProfileScopedKey.of(payloadKey), null)
actual fun savePayload(payload: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(payloadKey), payload)
?.apply()
}
}

View file

@ -5,7 +5,7 @@ import java.time.Instant
internal actual object TraktPlatformClock { internal actual object TraktPlatformClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis() actual fun nowEpochMs(): Long = System.currentTimeMillis()
actual fun parseIsoDateTimeToEpochMs(value: String): Long? = runCatching { actual fun parseIsoDateTimeToEpochMs(value: String): Long? =
Instant.parse(value).toEpochMilli() runCatching { Instant.parse(value).toEpochMilli() }.getOrNull()
}.getOrNull() ?: parseTraktIsoDateTimeToEpochMs(value)
} }

View file

@ -391,6 +391,9 @@
<string name="compose_settings_root_switch_profile_description">Change to a different profile.</string> <string name="compose_settings_root_switch_profile_description">Change to a different profile.</string>
<string name="compose_settings_root_switch_profile_title">Switch Profile</string> <string name="compose_settings_root_switch_profile_title">Switch Profile</string>
<string name="compose_settings_root_trakt_description">Open Trakt connection screen</string> <string name="compose_settings_root_trakt_description">Open Trakt connection screen</string>
<string name="settings_search_empty">No settings found.</string>
<string name="settings_search_placeholder">Search settings...</string>
<string name="settings_search_results_section">RESULTS</string>
<string name="compose_trakt_list_picker_loading">Loading your Trakt lists…</string> <string name="compose_trakt_list_picker_loading">Loading your Trakt lists…</string>
<string name="compose_trakt_list_picker_subtitle">Choose where to save this title on Trakt</string> <string name="compose_trakt_list_picker_subtitle">Choose where to save this title on Trakt</string>
<string name="action_donate">Donate</string> <string name="action_donate">Donate</string>
@ -1113,6 +1116,7 @@
<string name="downloads_live_failed">Download failed</string> <string name="downloads_live_failed">Download failed</string>
<string name="downloads_live_paused">Paused %1$s</string> <string name="downloads_live_paused">Paused %1$s</string>
<string name="library_remove_confirm">Remove</string> <string name="library_remove_confirm">Remove</string>
<string name="library_remove_from_list_message">Remove %1$s from %2$s?</string>
<string name="library_remove_message">Remove %1$s from your library?</string> <string name="library_remove_message">Remove %1$s from your library?</string>
<string name="library_remove_title">Remove from Library?</string> <string name="library_remove_title">Remove from Library?</string>
<string name="media_movie">Movie</string> <string name="media_movie">Movie</string>

View file

@ -512,6 +512,7 @@ private fun MainAppContent(
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle() val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle()
val liquidGlassNativeTabBarEnabled by remember { val liquidGlassNativeTabBarEnabled by remember {
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
@ -975,6 +976,7 @@ private fun MainAppContent(
val isTabletLayout = maxWidth >= 768.dp val isTabletLayout = maxWidth >= 768.dp
val useNativeBottomTabs = val useNativeBottomTabs =
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute<TabsRoute>() == true
val onProfileSelected: (NuvioProfile) -> Unit = { profile -> val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
profileSwitchLoading = true profileSwitchLoading = true
selectedTab = AppScreenTab.Home selectedTab = AppScreenTab.Home
@ -1033,6 +1035,7 @@ private fun MainAppContent(
.fillMaxSize() .fillMaxSize()
.padding(innerPadding), .padding(innerPadding),
selectedTab = selectedTab, selectedTab = selectedTab,
animateHomeCollectionGifs = tabsRouteActive,
onCatalogClick = onCatalogClick, onCatalogClick = onCatalogClick,
onPosterClick = { meta -> onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id)) navController.navigate(DetailRoute(type = meta.type, id = meta.id))
@ -1952,6 +1955,7 @@ private fun rememberGuardedPopBackStack(
private fun AppTabHost( private fun AppTabHost(
selectedTab: AppScreenTab, selectedTab: AppScreenTab,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
animateHomeCollectionGifs: Boolean = true,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null, onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null,
@ -1981,6 +1985,7 @@ private fun AppTabHost(
AppScreenTab.Home -> { AppScreenTab.Home -> {
HomeScreen( HomeScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
animateCollectionGifs = animateHomeCollectionGifs,
onCatalogClick = onCatalogClick, onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick, onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick, onPosterLongClick = onPosterLongClick,

View file

@ -3,6 +3,7 @@ package com.nuvio.app.core.storage
import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.catalog.CatalogRepository
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository
@ -44,6 +45,7 @@ internal object LocalAccountDataCleaner {
WatchedRepository.clearLocalState() WatchedRepository.clearLocalState()
ContinueWatchingPreferencesRepository.clearLocalState() ContinueWatchingPreferencesRepository.clearLocalState()
EpisodeReleaseNotificationsRepository.clearLocalState() EpisodeReleaseNotificationsRepository.clearLocalState()
CollectionMobileSettingsRepository.clearLocalState()
CollectionRepository.clearLocalState() CollectionRepository.clearLocalState()
ThemeSettingsRepository.clearLocalState() ThemeSettingsRepository.clearLocalState()
PosterCardStyleRepository.clearLocalState() PosterCardStyleRepository.clearLocalState()

View file

@ -4,6 +4,8 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
import com.nuvio.app.features.details.MetaScreenSettingsStorage import com.nuvio.app.features.details.MetaScreenSettingsStorage
import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.mdblist.MdbListMetadataService import com.nuvio.app.features.mdblist.MdbListMetadataService
@ -158,6 +160,7 @@ object ProfileSettingsSync {
TmdbSettingsRepository.uiState.map { "tmdb" }, TmdbSettingsRepository.uiState.map { "tmdb" },
MdbListSettingsRepository.uiState.map { "mdblist" }, MdbListSettingsRepository.uiState.map { "mdblist" },
MetaScreenSettingsRepository.uiState.map { "meta" }, MetaScreenSettingsRepository.uiState.map { "meta" },
CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" },
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" }, ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
TraktSettingsRepository.uiState.map { "trakt_settings" }, TraktSettingsRepository.uiState.map { "trakt_settings" },
TraktCommentsSettings.enabled.map { "trakt_comments" }, TraktCommentsSettings.enabled.map { "trakt_comments" },
@ -202,6 +205,7 @@ object ProfileSettingsSync {
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(), tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(), mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(), metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
collectionMobileSettingsPayload = CollectionMobileSettingsStorage.loadPayload().orEmpty().trim(),
continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(), continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(),
traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(), traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(),
traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(), traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(),
@ -232,6 +236,9 @@ object ProfileSettingsSync {
MetaScreenSettingsStorage.savePayload(blob.features.metaScreenSettingsPayload) MetaScreenSettingsStorage.savePayload(blob.features.metaScreenSettingsPayload)
MetaScreenSettingsRepository.onProfileChanged() MetaScreenSettingsRepository.onProfileChanged()
CollectionMobileSettingsStorage.savePayload(blob.features.collectionMobileSettingsPayload)
CollectionMobileSettingsRepository.onProfileChanged()
ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload) ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload)
ContinueWatchingPreferencesRepository.onProfileChanged() ContinueWatchingPreferencesRepository.onProfileChanged()
@ -251,6 +258,7 @@ object ProfileSettingsSync {
TmdbSettingsRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded()
MdbListSettingsRepository.ensureLoaded() MdbListSettingsRepository.ensureLoaded()
MetaScreenSettingsRepository.ensureLoaded() MetaScreenSettingsRepository.ensureLoaded()
CollectionMobileSettingsRepository.ensureLoaded()
ContinueWatchingPreferencesRepository.ensureLoaded() ContinueWatchingPreferencesRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded() TraktSettingsRepository.ensureLoaded()
TraktCommentsSettings.ensureLoaded() TraktCommentsSettings.ensureLoaded()
@ -272,6 +280,7 @@ object ProfileSettingsSync {
"tmdb=${TmdbSettingsRepository.uiState.value}", "tmdb=${TmdbSettingsRepository.uiState.value}",
"mdblist=${MdbListSettingsRepository.uiState.value}", "mdblist=${MdbListSettingsRepository.uiState.value}",
"meta=${MetaScreenSettingsRepository.uiState.value}", "meta=${MetaScreenSettingsRepository.uiState.value}",
"collection_mobile_settings=${CollectionMobileSettingsRepository.uiState.value}",
"continue=${ContinueWatchingPreferencesRepository.uiState.value}", "continue=${ContinueWatchingPreferencesRepository.uiState.value}",
"trakt_settings=${TraktSettingsRepository.uiState.value}", "trakt_settings=${TraktSettingsRepository.uiState.value}",
"trakt_comments=${TraktCommentsSettings.enabled.value}", "trakt_comments=${TraktCommentsSettings.enabled.value}",
@ -293,6 +302,7 @@ private data class MobileProfileSettingsFeatures(
@SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()), @SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()), @SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "", @SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",
@SerialName("collection_mobile_settings_payload") val collectionMobileSettingsPayload: String = "",
@SerialName("continue_watching_settings_payload") val continueWatchingSettingsPayload: String = "", @SerialName("continue_watching_settings_payload") val continueWatchingSettingsPayload: String = "",
@SerialName("trakt_settings_payload") val traktSettingsPayload: String = "", @SerialName("trakt_settings_payload") val traktSettingsPayload: String = "",
@SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()), @SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()),

View file

@ -44,9 +44,9 @@ private fun buildColorScheme(palette: ThemeColorPalette, amoled: Boolean = false
onSecondary = palette.onSecondaryVariant, onSecondary = palette.onSecondaryVariant,
background = if (amoled) Color.Black else palette.background, background = if (amoled) Color.Black else palette.background,
onBackground = Color(0xFFF5F7F8), onBackground = Color(0xFFF5F7F8),
surface = if (amoled) Color(0xFF050505) else palette.backgroundElevated, surface = palette.backgroundElevated,
onSurface = Color(0xFFF5F7F8), onSurface = Color(0xFFF5F7F8),
surfaceVariant = if (amoled) Color(0xFF0A0A0A) else palette.backgroundCard, surfaceVariant = palette.backgroundCard,
onSurfaceVariant = Color(0xFF969CA3), onSurfaceVariant = Color(0xFF969CA3),
outline = Color(0xFF252A2A), outline = Color(0xFF252A2A),
error = Color(0xFFE36A8A), error = Color(0xFFE36A8A),

View file

@ -12,11 +12,15 @@ internal fun buildAddonResourceUrl(
): String { ): String {
val encodedId = id.encodeAddonPathSegment() val encodedId = id.encodeAddonPathSegment()
val baseUrl = addonTransportBaseUrl(manifestUrl) val baseUrl = addonTransportBaseUrl(manifestUrl)
return if (extraPathSegment.isNullOrEmpty()) { val query = manifestUrl.substringAfter("?", "").let { query ->
if (query.isBlank()) "" else "?$query"
}
val resourceUrl = if (extraPathSegment.isNullOrEmpty()) {
"$baseUrl/$resource/$type/$encodedId.json" "$baseUrl/$resource/$type/$encodedId.json"
} else { } else {
"$baseUrl/$resource/$type/$encodedId/$extraPathSegment.json" "$baseUrl/$resource/$type/$encodedId/$extraPathSegment.json"
} }
return resourceUrl + query
} }

View file

@ -195,10 +195,10 @@ object CollectionEditorRepository {
) )
} }
fun updateFolderFocusGifEnabled(enabled: Boolean) { fun updateFolderMobileFocusGifEnabled(enabled: Boolean) {
val folder = _uiState.value.editingFolder ?: return val folder = _uiState.value.editingFolder ?: return
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
editingFolder = folder.copy(focusGifEnabled = enabled), editingFolder = folder.copy(mobileFocusGifEnabled = enabled),
) )
} }
@ -808,6 +808,8 @@ object CollectionEditorRepository {
folders = state.folders, folders = state.folders,
) )
CollectionMobileSettingsRepository.replaceCollectionFolderGifSettings(collection.id, collection.folders)
if (state.isNew) { if (state.isNew) {
CollectionRepository.addCollection(collection) CollectionRepository.addCollection(collection)
} else { } else {

View file

@ -702,8 +702,8 @@ private fun FolderEditorPage(
FolderEditorToggleRow( FolderEditorToggleRow(
title = stringResource(Res.string.collections_editor_show_gif_when_configured), title = stringResource(Res.string.collections_editor_show_gif_when_configured),
subtitle = stringResource(Res.string.collections_editor_show_gif_when_configured_desc), subtitle = stringResource(Res.string.collections_editor_show_gif_when_configured_desc),
checked = folder.focusGifEnabled, checked = folder.mobileFocusGifEnabled,
onCheckedChange = { CollectionEditorRepository.updateFolderFocusGifEnabled(it) }, onCheckedChange = { CollectionEditorRepository.updateFolderMobileFocusGifEnabled(it) },
) )
FolderEditorToggleRow( FolderEditorToggleRow(

View file

@ -0,0 +1,155 @@
package com.nuvio.app.features.collection
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
data class CollectionMobileSettingsUiState(
val folderGifOverrides: Map<String, Boolean> = emptyMap(),
)
object CollectionMobileSettingsRepository {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val _uiState = MutableStateFlow(CollectionMobileSettingsUiState())
val uiState: StateFlow<CollectionMobileSettingsUiState> = _uiState.asStateFlow()
private var hasLoaded = false
fun ensureLoaded() {
if (hasLoaded) return
loadFromDisk()
}
fun onProfileChanged() {
loadFromDisk()
CollectionRepository.onMobileSettingsChanged()
}
fun clearLocalState() {
hasLoaded = false
_uiState.value = CollectionMobileSettingsUiState()
}
fun isFolderGifEnabled(collectionId: String, folderId: String): Boolean {
ensureLoaded()
return _uiState.value.folderGifOverrides[folderKey(collectionId, folderId)] ?: true
}
fun applyToCollections(collections: List<Collection>): List<Collection> {
ensureLoaded()
return collections.map(::applyToCollection)
}
fun applyToCollection(collection: Collection): Collection {
ensureLoaded()
return collection.copy(
folders = collection.folders.map { folder ->
folder.copy(
mobileFocusGifEnabled = isFolderGifEnabled(
collectionId = collection.id,
folderId = folder.id,
),
)
},
)
}
fun replaceCollectionFolderGifSettings(collectionId: String, folders: List<CollectionFolder>) {
ensureLoaded()
val collectionPrefix = "${collectionId.trim()}$FolderKeySeparator"
val next = _uiState.value.folderGifOverrides
.filterKeys { key -> !key.startsWith(collectionPrefix) }
.toMutableMap()
folders.forEach { folder ->
val key = folderKey(collectionId, folder.id)
if (folder.mobileFocusGifEnabled) {
next.remove(key)
} else {
next[key] = false
}
}
_uiState.value = CollectionMobileSettingsUiState(folderGifOverrides = next)
persist()
CollectionRepository.onMobileSettingsChanged()
}
private fun loadFromDisk() {
hasLoaded = true
val payload = CollectionMobileSettingsStorage.loadPayload().orEmpty().trim()
if (payload.isEmpty()) {
_uiState.value = CollectionMobileSettingsUiState()
return
}
val stored = runCatching {
json.decodeFromString<StoredCollectionMobileSettingsPayload>(payload)
}.getOrNull()
_uiState.value = CollectionMobileSettingsUiState(
folderGifOverrides = stored
?.folderGifOverrides
.orEmpty()
.mapNotNull { item ->
if (item.collectionId.isBlank() || item.folderId.isBlank()) {
null
} else {
folderKey(item.collectionId, item.folderId) to item.enabled
}
}
.toMap(),
)
}
private fun persist() {
if (_uiState.value.folderGifOverrides.isEmpty()) {
CollectionMobileSettingsStorage.savePayload("")
return
}
val payload = StoredCollectionMobileSettingsPayload(
folderGifOverrides = _uiState.value.folderGifOverrides
.mapNotNull { (key, enabled) ->
val parts = key.split(FolderKeySeparator, limit = 2)
val collectionId = parts.getOrNull(0).orEmpty()
val folderId = parts.getOrNull(1).orEmpty()
if (collectionId.isBlank() || folderId.isBlank()) {
null
} else {
StoredFolderGifOverride(
collectionId = collectionId,
folderId = folderId,
enabled = enabled,
)
}
}
.sortedWith(compareBy<StoredFolderGifOverride> { it.collectionId }.thenBy { it.folderId }),
)
CollectionMobileSettingsStorage.savePayload(json.encodeToString(payload))
}
private fun folderKey(collectionId: String, folderId: String): String =
"${collectionId.trim()}$FolderKeySeparator${folderId.trim()}"
}
private const val FolderKeySeparator = "\u001F"
@Serializable
private data class StoredCollectionMobileSettingsPayload(
@SerialName("folder_gif_overrides") val folderGifOverrides: List<StoredFolderGifOverride> = emptyList(),
)
@Serializable
private data class StoredFolderGifOverride(
@SerialName("collection_id") val collectionId: String,
@SerialName("folder_id") val folderId: String,
val enabled: Boolean = true,
)

View file

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

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.PosterShape
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
enum class FolderViewMode { enum class FolderViewMode {
TABBED_GRID, TABBED_GRID,
@ -13,7 +14,7 @@ enum class FolderViewMode {
companion object { companion object {
fun fromString(value: String): FolderViewMode = fun fromString(value: String): FolderViewMode =
when { when {
value.equals(FOLLOW_LAYOUT.name, ignoreCase = true) -> ROWS value.equals(FOLLOW_LAYOUT.name, ignoreCase = true) -> FOLLOW_LAYOUT
value.equals(ROWS.name, ignoreCase = true) -> ROWS value.equals(ROWS.name, ignoreCase = true) -> ROWS
value.equals(TABBED_GRID.name, ignoreCase = true) -> TABBED_GRID value.equals(TABBED_GRID.name, ignoreCase = true) -> TABBED_GRID
else -> TABBED_GRID else -> TABBED_GRID
@ -168,6 +169,8 @@ data class CollectionFolder(
val coverImageUrl: String? = null, val coverImageUrl: String? = null,
val focusGifUrl: String? = null, val focusGifUrl: String? = null,
val focusGifEnabled: Boolean = true, val focusGifEnabled: Boolean = true,
@Transient
val mobileFocusGifEnabled: Boolean = true,
val coverEmoji: String? = null, val coverEmoji: String? = null,
val tileShape: String = "poster", val tileShape: String = "poster",
val hideTitle: Boolean = false, val hideTitle: Boolean = false,

View file

@ -52,7 +52,8 @@ object CollectionRepository {
runCatching { runCatching {
val parsed = json.parseToJsonElement(payload) val parsed = json.parseToJsonElement(payload)
rawCollectionsJson = parsed rawCollectionsJson = parsed
_collections.value = json.decodeFromString<List<Collection>>(payload) val decoded = json.decodeFromString<List<Collection>>(payload)
_collections.value = CollectionMobileSettingsRepository.applyToCollections(decoded)
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Failed to load collections from storage" } log.e(e) { "Failed to load collections from storage" }
} }
@ -75,14 +76,15 @@ object CollectionRepository {
fun addCollection(collection: Collection) { fun addCollection(collection: Collection) {
ensureLoaded() ensureLoaded()
_collections.value = _collections.value + collection _collections.value = _collections.value + CollectionMobileSettingsRepository.applyToCollection(collection)
persist() persist()
} }
fun updateCollection(collection: Collection) { fun updateCollection(collection: Collection) {
ensureLoaded() ensureLoaded()
val decorated = CollectionMobileSettingsRepository.applyToCollection(collection)
_collections.value = _collections.value.map { _collections.value = _collections.value.map {
if (it.id == collection.id) collection else it if (it.id == collection.id) decorated else it
} }
persist() persist()
} }
@ -95,7 +97,7 @@ object CollectionRepository {
fun setCollections(collections: List<Collection>) { fun setCollections(collections: List<Collection>) {
ensureLoaded() ensureLoaded()
_collections.value = collections _collections.value = CollectionMobileSettingsRepository.applyToCollections(collections)
persist() persist()
} }
@ -127,7 +129,7 @@ object CollectionRepository {
return runCatching { return runCatching {
rawCollectionsJson = json.parseToJsonElement(jsonString) rawCollectionsJson = json.parseToJsonElement(jsonString)
val imported = json.decodeFromString<List<Collection>>(jsonString) val imported = json.decodeFromString<List<Collection>>(jsonString)
_collections.value = imported _collections.value = CollectionMobileSettingsRepository.applyToCollections(imported)
persist() persist()
imported imported
} }
@ -262,10 +264,15 @@ object CollectionRepository {
internal fun applyFromRemote(collections: List<Collection>, rawJson: JsonElement) { internal fun applyFromRemote(collections: List<Collection>, rawJson: JsonElement) {
rawCollectionsJson = rawJson rawCollectionsJson = rawJson
_collections.value = collections _collections.value = CollectionMobileSettingsRepository.applyToCollections(collections)
persist(sync = false) persist(sync = false)
} }
internal fun onMobileSettingsChanged() {
if (!hasLoaded) return
_collections.value = CollectionMobileSettingsRepository.applyToCollections(_collections.value)
}
private fun ensureLoaded() { private fun ensureLoaded() {
if (!hasLoaded) initialize() if (!hasLoaded) initialize()
} }

View file

@ -70,6 +70,7 @@ import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun HomeScreen( fun HomeScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
animateCollectionGifs: Boolean = true,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null, onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null,
@ -560,6 +561,7 @@ fun HomeScreen(
collection = collection, collection = collection,
modifier = Modifier.padding(bottom = 12.dp), modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding, sectionPadding = homeSectionPadding,
animateGifs = animateCollectionGifs,
onFolderClick = onFolderClick, onFolderClick = onFolderClick,
) )
} }

View file

@ -37,6 +37,7 @@ fun HomeCollectionRowSection(
collection: Collection, collection: Collection,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sectionPadding: Dp? = null, sectionPadding: Dp? = null,
animateGifs: Boolean = true,
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null, onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
) { ) {
if (collection.folders.isEmpty()) return if (collection.folders.isEmpty()) return
@ -46,6 +47,7 @@ fun HomeCollectionRowSection(
collection = collection, collection = collection,
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
sectionPadding = sectionPadding, sectionPadding = sectionPadding,
animateGifs = animateGifs,
onFolderClick = onFolderClick, onFolderClick = onFolderClick,
) )
} else { } else {
@ -54,6 +56,7 @@ fun HomeCollectionRowSection(
collection = collection, collection = collection,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value), sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
animateGifs = animateGifs,
onFolderClick = onFolderClick, onFolderClick = onFolderClick,
) )
} }
@ -65,6 +68,7 @@ private fun HomeCollectionRowSectionContent(
collection: Collection, collection: Collection,
modifier: Modifier, modifier: Modifier,
sectionPadding: Dp, sectionPadding: Dp,
animateGifs: Boolean,
onFolderClick: ((collectionId: String, folderId: String) -> Unit)?, onFolderClick: ((collectionId: String, folderId: String) -> Unit)?,
) { ) {
NuvioShelfSection( NuvioShelfSection(
@ -77,6 +81,7 @@ private fun HomeCollectionRowSectionContent(
) { folder -> ) { folder ->
CollectionFolderCard( CollectionFolderCard(
folder = folder, folder = folder,
animateGifs = animateGifs,
onClick = onFolderClick?.let { { it(collection.id, folder.id) } }, onClick = onFolderClick?.let { { it(collection.id, folder.id) } },
) )
} }
@ -86,6 +91,7 @@ private fun HomeCollectionRowSectionContent(
private fun CollectionFolderCard( private fun CollectionFolderCard(
folder: CollectionFolder, folder: CollectionFolder,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
animateGifs: Boolean = true,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
) { ) {
val posterCardStyle = rememberPosterCardStyleUiState() val posterCardStyle = rememberPosterCardStyleUiState()
@ -138,7 +144,7 @@ private fun CollectionFolderCard(
contentDescription = folder.title, contentDescription = folder.title,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
animateIfPossible = isAnimatedCollectionFolderImage(folder, imageUrl), animateIfPossible = animateGifs && isAnimatedCollectionFolderImage(folder, imageUrl),
) )
} }
!folder.coverEmoji.isNullOrBlank() -> { !folder.coverEmoji.isNullOrBlank() -> {
@ -180,7 +186,7 @@ private fun CollectionFolderCard(
} }
private fun collectionFolderCardImageUrl(folder: CollectionFolder): String? { private fun collectionFolderCardImageUrl(folder: CollectionFolder): String? {
return if (folder.focusGifEnabled) { return if (folder.mobileFocusGifEnabled) {
firstNonBlank(folder.focusGifUrl, folder.coverImageUrl) firstNonBlank(folder.focusGifUrl, folder.coverImageUrl)
} else { } else {
firstNonBlank(folder.coverImageUrl) firstNonBlank(folder.coverImageUrl)
@ -196,5 +202,5 @@ private fun isAnimatedCollectionFolderImage(
imageUrl: String, imageUrl: String,
): Boolean { ): Boolean {
val gifUrl = firstNonBlank(folder.focusGifUrl) ?: return false val gifUrl = firstNonBlank(folder.focusGifUrl) ?: return false
return folder.focusGifEnabled && imageUrl == gifUrl return folder.mobileFocusGifEnabled && imageUrl == gifUrl
} }

View file

@ -296,6 +296,14 @@ object LibraryRepository {
} }
} }
suspend fun removeFromList(item: LibraryItem, listKey: String) {
val desiredMembership = libraryMembershipWithRemovedList(
currentMembership = getMembershipSnapshot(item),
listKey = listKey,
)
applyMembershipChanges(item, desiredMembership)
}
private fun pushToServer() { private fun pushToServer() {
syncScope.launch { syncScope.launch {
runCatching { runCatching {
@ -417,6 +425,14 @@ internal fun libraryMembershipWithLocal(
putAll(traktMembership) putAll(traktMembership)
} }
internal fun libraryMembershipWithRemovedList(
currentMembership: Map<String, Boolean>,
listKey: String,
): Map<String, Boolean> =
currentMembership.toMutableMap().apply {
this[listKey] = false
}
private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem( private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem(
id = contentId, id = contentId,
type = contentType, type = contentType,

View file

@ -25,6 +25,7 @@ import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.core.ui.NuvioViewAllPillSize import com.nuvio.app.core.ui.NuvioViewAllPillSize
import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomeEmptyStateCard
@ -33,8 +34,15 @@ import com.nuvio.app.features.home.components.HomeSkeletonRow
import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileRepository
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
private data class LibraryRemovalTarget(
val item: LibraryItem,
val listKey: String? = null,
val listTitle: String? = null,
)
@Composable @Composable
fun LibraryScreen( fun LibraryScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -46,7 +54,7 @@ fun LibraryScreen(
LibraryRepository.uiState LibraryRepository.uiState
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
var pendingRemovalItem by remember { mutableStateOf<LibraryItem?>(null) } var pendingRemovalTarget by remember { mutableStateOf<LibraryRemovalTarget?>(null) }
var observedOfflineState by remember { mutableStateOf(false) } var observedOfflineState by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
@ -165,9 +173,15 @@ fun LibraryScreen(
sections = uiState.sections, sections = uiState.sections,
onPosterClick = onPosterClick, onPosterClick = onPosterClick,
onSectionViewAllClick = onSectionViewAllClick, onSectionViewAllClick = onSectionViewAllClick,
onPosterLongClick = { item -> onPosterLongClick = { item, section ->
if (!isTraktSource) { pendingRemovalTarget = if (isTraktSource) {
pendingRemovalItem = item LibraryRemovalTarget(
item = item,
listKey = section.type,
listTitle = section.displayTitle,
)
} else {
LibraryRemovalTarget(item = item)
} }
}, },
) )
@ -177,17 +191,38 @@ fun LibraryScreen(
NuvioStatusModal( NuvioStatusModal(
title = stringResource(Res.string.library_remove_title), title = stringResource(Res.string.library_remove_title),
message = pendingRemovalItem?.let { message = pendingRemovalTarget?.let { target ->
stringResource(Res.string.library_remove_message, it.name) val listTitle = target.listTitle
if (listTitle.isNullOrBlank()) {
stringResource(Res.string.library_remove_message, target.item.name)
} else {
stringResource(Res.string.library_remove_from_list_message, target.item.name, listTitle)
}
}.orEmpty(), }.orEmpty(),
isVisible = pendingRemovalItem != null, isVisible = pendingRemovalTarget != null,
confirmText = stringResource(Res.string.library_remove_confirm), confirmText = stringResource(Res.string.library_remove_confirm),
dismissText = stringResource(Res.string.action_cancel), dismissText = stringResource(Res.string.action_cancel),
onConfirm = { onConfirm = {
pendingRemovalItem?.id?.let(LibraryRepository::remove) val target = pendingRemovalTarget
pendingRemovalItem = null pendingRemovalTarget = null
target?.let {
val listKey = target.listKey
if (listKey.isNullOrBlank()) {
LibraryRepository.remove(target.item.id)
} else {
coroutineScope.launch {
runCatching {
LibraryRepository.removeFromList(target.item, listKey)
}.onFailure { error ->
NuvioToastController.show(
error.message ?: getString(Res.string.trakt_lists_update_failed),
)
}
}
}
}
}, },
onDismiss = { pendingRemovalItem = null }, onDismiss = { pendingRemovalTarget = null },
) )
} }
@ -195,7 +230,7 @@ private fun LazyListScope.librarySections(
sections: List<LibrarySection>, sections: List<LibrarySection>,
onPosterClick: ((LibraryItem) -> Unit)?, onPosterClick: ((LibraryItem) -> Unit)?,
onSectionViewAllClick: ((LibrarySection) -> Unit)?, onSectionViewAllClick: ((LibrarySection) -> Unit)?,
onPosterLongClick: (LibraryItem) -> Unit, onPosterLongClick: (LibraryItem, LibrarySection) -> Unit,
) { ) {
items( items(
items = sections, items = sections,
@ -218,7 +253,7 @@ private fun LazyListScope.librarySections(
HomePosterCard( HomePosterCard(
item = item.toMetaPreview(), item = item.toMetaPreview(),
onClick = onPosterClick?.let { { it(item) } }, onClick = onPosterClick?.let { { it(item) } },
onLongClick = { onPosterLongClick(item) }, onLongClick = { onPosterLongClick(item, section) },
) )
} }
} }

View file

@ -1449,14 +1449,16 @@ fun PlayerScreen(
totalDy += delta.y totalDy += delta.y
if (gestureMode == null) { if (gestureMode == null) {
val holdToSpeedActive = isHoldToSpeedGestureActiveState.value
val horizontalDominant = val horizontalDominant =
!isHoldToSpeedGestureActiveState.value && !holdToSpeedActive &&
abs(totalDx) > viewConfiguration.touchSlop && abs(totalDx) > viewConfiguration.touchSlop &&
abs(totalDx) > abs(totalDy) abs(totalDx) > abs(totalDy)
val verticalDominant = val verticalDominant =
playerSettingsUiState.swipeGesturesEnabled && playerSettingsUiState.swipeGesturesEnabled &&
abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx) !holdToSpeedActive &&
abs(totalDy) > viewConfiguration.touchSlop &&
abs(totalDy) > abs(totalDx)
gestureMode = when { gestureMode = when {
horizontalDominant -> { horizontalDominant -> {
deactivateHoldToSpeedState.value() deactivateHoldToSpeedState.value()

View file

@ -6,6 +6,7 @@ import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.auth.isAnonymous import com.nuvio.app.core.auth.isAnonymous
import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.downloads.DownloadsRepository import com.nuvio.app.features.downloads.DownloadsRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository
@ -156,6 +157,7 @@ object ProfileRepository {
TraktAuthRepository.onProfileChanged() TraktAuthRepository.onProfileChanged()
SearchHistoryRepository.onProfileChanged() SearchHistoryRepository.onProfileChanged()
CollectionRepository.onProfileChanged() CollectionRepository.onProfileChanged()
CollectionMobileSettingsRepository.onProfileChanged()
DownloadsRepository.onProfileChanged() DownloadsRepository.onProfileChanged()
} }

View file

@ -33,6 +33,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -220,7 +221,14 @@ fun SearchScreen(
androidx.compose.foundation.layout.Column( androidx.compose.foundation.layout.Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background)
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent()
}
}
},
) { ) {
NuvioScreenHeader( NuvioScreenHeader(
title = headerTitle, title = headerTitle,
@ -277,7 +285,15 @@ fun SearchScreen(
onPosterLongClick = onPosterLongClick, onPosterLongClick = onPosterLongClick,
) )
} else { } else {
val normalizedQuery = query.trim()
val isWaitingForSearch = normalizedQuery.isNotBlank() && lastRequestedQuery != normalizedQuery
when { when {
isWaitingForSearch -> {
items(2) {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
}
}
uiState.isLoading && uiState.sections.isEmpty() -> { uiState.isLoading && uiState.sections.isEmpty() -> {
items(2) { items(2) {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
@ -291,7 +307,6 @@ fun SearchScreen(
errorMessage = uiState.errorMessage, errorMessage = uiState.errorMessage,
networkCondition = networkStatusUiState.condition, networkCondition = networkStatusUiState.condition,
onRetry = { onRetry = {
val normalizedQuery = query.trim()
if (normalizedQuery.isNotBlank()) { if (normalizedQuery.isNotBlank()) {
NetworkStatusRepository.requestRefresh(force = true) NetworkStatusRepository.requestRefresh(force = true)
SearchRepository.search( SearchRepository.search(
@ -300,6 +315,7 @@ fun SearchScreen(
) )
} }
}, },
modifier = Modifier.padding(horizontal = homeSectionPadding),
) )
} }
} }
@ -322,7 +338,7 @@ fun SearchScreen(
} }
} }
} }
} }
} }
private fun discoverColumnCountForWidth(screenWidth: Dp): Int = private fun discoverColumnCountForWidth(screenWidth: Dp): Int =

View file

@ -7,9 +7,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -20,24 +17,17 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.auth.isAnonymous
import com.nuvio.app.core.ui.NuvioPrimaryButton import com.nuvio.app.core.ui.NuvioPrimaryButton
import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.core.ui.NuvioSurfaceCard
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel import nuvio.composeapp.generated.resources.action_cancel
import nuvio.composeapp.generated.resources.action_delete
import nuvio.composeapp.generated.resources.compose_settings_page_account import nuvio.composeapp.generated.resources.compose_settings_page_account
import nuvio.composeapp.generated.resources.settings_account_delete_account
import nuvio.composeapp.generated.resources.settings_account_delete_account_description
import nuvio.composeapp.generated.resources.settings_account_delete_confirm_message
import nuvio.composeapp.generated.resources.settings_account_delete_confirm_title
import nuvio.composeapp.generated.resources.settings_account_email import nuvio.composeapp.generated.resources.settings_account_email
import nuvio.composeapp.generated.resources.settings_account_not_signed_in import nuvio.composeapp.generated.resources.settings_account_not_signed_in
import nuvio.composeapp.generated.resources.settings_account_sign_out import nuvio.composeapp.generated.resources.settings_account_sign_out
@ -62,7 +52,6 @@ private fun AccountSettingsBody(
) { ) {
val authState by AuthRepository.state.collectAsStateWithLifecycle() val authState by AuthRepository.state.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showDeleteConfirm by remember { mutableStateOf(false) }
var showSignOutConfirm by remember { mutableStateOf(false) } var showSignOutConfirm by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
@ -131,35 +120,6 @@ private fun AccountSettingsBody(
text = stringResource(Res.string.settings_account_sign_out), text = stringResource(Res.string.settings_account_sign_out),
onClick = { showSignOutConfirm = true }, onClick = { showSignOutConfirm = true },
) )
if (authState is AuthState.Authenticated && !(authState as AuthState.Authenticated).isAnonymous) {
Spacer(modifier = Modifier.height(20.dp))
Button(
onClick = { showDeleteConfirm = true },
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text(
text = stringResource(Res.string.settings_account_delete_account),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
}
Text(
text = stringResource(Res.string.settings_account_delete_account_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
}
} }
NuvioStatusModal( NuvioStatusModal(
@ -174,17 +134,4 @@ private fun AccountSettingsBody(
}, },
onDismiss = { showSignOutConfirm = false }, onDismiss = { showSignOutConfirm = false },
) )
NuvioStatusModal(
title = stringResource(Res.string.settings_account_delete_confirm_title),
message = stringResource(Res.string.settings_account_delete_confirm_message),
isVisible = showDeleteConfirm,
confirmText = stringResource(Res.string.action_delete),
dismissText = stringResource(Res.string.action_cancel),
onConfirm = {
showDeleteConfirm = false
scope.launch { AuthRepository.deleteAccount() }
},
onDismiss = { showDeleteConfirm = false },
)
} }

View file

@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -29,10 +30,19 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max import androidx.compose.ui.unit.max
@ -66,8 +76,14 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepositor
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState
import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.compose_settings_page_root import nuvio.composeapp.generated.resources.compose_settings_page_root
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
private val SettingsSearchRevealThreshold = 28.dp
private const val SettingsSearchRevealAnimationMillis = 240L
private const val SettingsSearchRevealHapticDelayMillis = 90L
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -331,7 +347,66 @@ private fun MobileSettingsScreen(
) { ) {
val saveableStateHolder = rememberSaveableStateHolder() val saveableStateHolder = rememberSaveableStateHolder()
saveableStateHolder.SaveableStateProvider(page.name) { saveableStateHolder.SaveableStateProvider(page.name) {
NuvioScreen { var settingsSearchQuery by rememberSaveable { mutableStateOf("") }
var rootSearchVisible by rememberSaveable { mutableStateOf(false) }
var rootSearchRevealAnimating by rememberSaveable { mutableStateOf(false) }
val listState = rememberLazyListState()
val hapticFeedback = LocalHapticFeedback.current
val hapticScope = rememberCoroutineScope()
val rootSearchRevealConnection = rememberSettingsRootSearchRevealConnection(
page = page,
listState = listState,
query = settingsSearchQuery,
searchVisible = rootSearchVisible,
) {
rootSearchVisible = true
rootSearchRevealAnimating = true
hapticScope.launch {
delay(SettingsSearchRevealHapticDelayMillis)
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}
val searchEntries = settingsSearchEntries(
pluginsEnabled = AppFeaturePolicy.pluginsEnabled,
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
switchProfileAvailable = onSwitchProfile != null,
checkForUpdatesAvailable = onCheckForUpdatesClick != null,
)
fun openSearchTarget(target: SettingsSearchTarget) {
when (target) {
is SettingsSearchTarget.Page -> when (target.page) {
SettingsPage.Account -> onAccountClick()
SettingsPage.SupportersContributors -> onSupportersContributorsClick()
SettingsPage.ContinueWatching -> onContinueWatchingClick()
SettingsPage.Addons -> onAddonsClick()
SettingsPage.Plugins -> {
if (AppFeaturePolicy.pluginsEnabled) {
onPluginsClick()
}
}
SettingsPage.Homescreen -> onHomescreenClick()
SettingsPage.MetaScreen -> onMetaScreenClick()
else -> onPageChange(target.page)
}
SettingsSearchTarget.Downloads -> onDownloadsClick()
SettingsSearchTarget.Collections -> onCollectionsClick()
SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke()
SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke()
}
}
LaunchedEffect(rootSearchRevealAnimating) {
if (rootSearchRevealAnimating) {
delay(SettingsSearchRevealAnimationMillis)
rootSearchRevealAnimating = false
}
}
NuvioScreen(
modifier = Modifier.nestedScroll(rootSearchRevealConnection),
listState = listState,
) {
stickyHeader { stickyHeader {
val previousPage = page.previousPage() val previousPage = page.previousPage()
NuvioScreenHeader( NuvioScreenHeader(
@ -341,7 +416,18 @@ private fun MobileSettingsScreen(
} }
when (page) { when (page) {
SettingsPage.Root -> settingsRootContent( SettingsPage.Root -> {
settingsSearchRootContent(
query = settingsSearchQuery,
entries = searchEntries,
isTablet = false,
showSearchField = rootSearchVisible,
animateSearchField = rootSearchRevealAnimating,
onQueryChange = { settingsSearchQuery = it },
onTargetClick = { openSearchTarget(it) },
)
if (settingsSearchQuery.isBlank()) {
settingsRootContent(
isTablet = false, isTablet = false,
onPlaybackClick = { onPageChange(SettingsPage.Playback) }, onPlaybackClick = { onPageChange(SettingsPage.Playback) },
onAppearanceClick = { onPageChange(SettingsPage.Appearance) }, onAppearanceClick = { onPageChange(SettingsPage.Appearance) },
@ -355,6 +441,8 @@ private fun MobileSettingsScreen(
onAccountClick = onAccountClick, onAccountClick = onAccountClick,
onSwitchProfileClick = onSwitchProfile, onSwitchProfileClick = onSwitchProfile,
) )
}
}
SettingsPage.Account -> accountSettingsContent( SettingsPage.Account -> accountSettingsContent(
isTablet = false, isTablet = false,
) )
@ -457,6 +545,48 @@ private fun MobileSettingsScreen(
} }
} }
@Composable
private fun rememberSettingsRootSearchRevealConnection(
page: SettingsPage,
listState: LazyListState,
query: String,
searchVisible: Boolean,
onReveal: () -> Unit,
): NestedScrollConnection {
val revealThresholdPx = with(LocalDensity.current) { SettingsSearchRevealThreshold.toPx() }
val currentOnReveal by rememberUpdatedState(onReveal)
var pullDistancePx by remember(page) { mutableStateOf(0f) }
var revealTriggered by remember(page) { mutableStateOf(false) }
return remember(page, listState, query, searchVisible, revealThresholdPx) {
object : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
val isRootAtTop = page == SettingsPage.Root &&
listState.firstVisibleItemIndex == 0 &&
listState.firstVisibleItemScrollOffset == 0
val canRevealSearch = isRootAtTop && !searchVisible && !revealTriggered && query.isBlank()
if (canRevealSearch && available.y > 0f) {
pullDistancePx += available.y
if (pullDistancePx >= revealThresholdPx) {
pullDistancePx = 0f
revealTriggered = true
currentOnReveal()
}
} else if (!isRootAtTop || available.y < 0f) {
pullDistancePx = 0f
}
return Offset.Zero
}
}
}
}
@Composable @Composable
private fun TabletSettingsScreen( private fun TabletSettingsScreen(
page: SettingsPage, page: SettingsPage,
@ -564,11 +694,54 @@ private fun TabletSettingsScreen(
} }
saveableStateHolder.SaveableStateProvider(page.name) { saveableStateHolder.SaveableStateProvider(page.name) {
var settingsSearchQuery by rememberSaveable { mutableStateOf("") }
var rootSearchVisible by rememberSaveable { mutableStateOf(false) }
var rootSearchRevealAnimating by rememberSaveable { mutableStateOf(false) }
val hapticFeedback = LocalHapticFeedback.current
val hapticScope = rememberCoroutineScope()
val searchEntries = settingsSearchEntries(
pluginsEnabled = AppFeaturePolicy.pluginsEnabled,
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
switchProfileAvailable = onSwitchProfile != null,
checkForUpdatesAvailable = onCheckForUpdatesClick != null,
)
fun openSearchTarget(target: SettingsSearchTarget) {
when (target) {
is SettingsSearchTarget.Page -> openInlinePage(target.page)
SettingsSearchTarget.Downloads -> onDownloadsClick()
SettingsSearchTarget.Collections -> onCollectionsClick()
SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke()
SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke()
}
}
val listState = rememberLazyListState() val listState = rememberLazyListState()
val bottomOverlayPadding = LocalNuvioBottomNavigationOverlayPadding.current val bottomOverlayPadding = LocalNuvioBottomNavigationOverlayPadding.current
val rootSearchRevealConnection = rememberSettingsRootSearchRevealConnection(
page = page,
listState = listState,
query = settingsSearchQuery,
searchVisible = rootSearchVisible,
) {
rootSearchVisible = true
rootSearchRevealAnimating = true
hapticScope.launch {
delay(SettingsSearchRevealHapticDelayMillis)
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}
LaunchedEffect(rootSearchRevealAnimating) {
if (rootSearchRevealAnimating) {
delay(SettingsSearchRevealAnimationMillis)
rootSearchRevealAnimating = false
}
}
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.nestedScroll(rootSearchRevealConnection),
contentPadding = PaddingValues( contentPadding = PaddingValues(
start = 40.dp, start = 40.dp,
top = topOffset, top = topOffset,
@ -581,7 +754,11 @@ private fun TabletSettingsScreen(
val previousPage = page.previousPage() val previousPage = page.previousPage()
TabletPageHeader( TabletPageHeader(
title = if (page == SettingsPage.Root) { title = if (page == SettingsPage.Root) {
if (settingsSearchQuery.isBlank()) {
stringResource(activeCategory.labelRes) stringResource(activeCategory.labelRes)
} else {
stringResource(Res.string.compose_settings_page_root)
}
} else { } else {
stringResource(page.titleRes) stringResource(page.titleRes)
}, },
@ -590,7 +767,18 @@ private fun TabletSettingsScreen(
) )
} }
when (page) { when (page) {
SettingsPage.Root -> settingsRootContent( SettingsPage.Root -> {
settingsSearchRootContent(
query = settingsSearchQuery,
entries = searchEntries,
isTablet = true,
showSearchField = rootSearchVisible,
animateSearchField = rootSearchRevealAnimating,
onQueryChange = { settingsSearchQuery = it },
onTargetClick = { openSearchTarget(it) },
)
if (settingsSearchQuery.isBlank()) {
settingsRootContent(
isTablet = true, isTablet = true,
onPlaybackClick = { openInlinePage(SettingsPage.Playback) }, onPlaybackClick = { openInlinePage(SettingsPage.Playback) },
onAppearanceClick = { openInlinePage(SettingsPage.Appearance) }, onAppearanceClick = { openInlinePage(SettingsPage.Appearance) },
@ -607,6 +795,8 @@ private fun TabletSettingsScreen(
showGeneralSection = activeCategory == SettingsCategory.General, showGeneralSection = activeCategory == SettingsCategory.General,
showAboutSection = activeCategory == SettingsCategory.About, showAboutSection = activeCategory == SettingsCategory.About,
) )
}
}
SettingsPage.Account -> accountSettingsContent( SettingsPage.Account -> accountSettingsContent(
isTablet = true, isTablet = true,
) )

View file

@ -0,0 +1,961 @@
package com.nuvio.app.features.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AccountCircle
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.CloudDownload
import androidx.compose.material.icons.rounded.CollectionsBookmark
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Favorite
import androidx.compose.material.icons.rounded.Hub
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Link
import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.People
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Style
import androidx.compose.material.icons.rounded.Tune
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.nuvio.app.isIos
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
internal sealed class SettingsSearchTarget {
data class Page(val page: SettingsPage) : SettingsSearchTarget()
object Downloads : SettingsSearchTarget()
object Collections : SettingsSearchTarget()
object SwitchProfile : SettingsSearchTarget()
object CheckForUpdates : SettingsSearchTarget()
}
internal data class SettingsSearchEntry(
val key: String,
val title: String,
val description: String,
val page: String,
val section: String,
val category: String,
val icon: ImageVector,
val target: SettingsSearchTarget,
) {
val searchableText: String = listOf(title, description, page, section, category)
.joinToString(separator = " ")
.lowercase()
val contextLabel: String = listOf(page, section)
.filter { it.isNotBlank() }
.distinct()
.joinToString(separator = " - ")
}
@Composable
internal fun settingsSearchEntries(
pluginsEnabled: Boolean,
liquidGlassNativeTabBarSupported: Boolean,
switchProfileAvailable: Boolean,
checkForUpdatesAvailable: Boolean,
): List<SettingsSearchEntry> {
val accountCategory = stringResource(SettingsCategory.Account.labelRes)
val generalCategory = stringResource(SettingsCategory.General.labelRes)
val aboutCategory = stringResource(SettingsCategory.About.labelRes)
val accountPage = stringResource(Res.string.compose_settings_page_account)
val traktPage = stringResource(Res.string.compose_settings_page_trakt)
val layoutPage = stringResource(Res.string.compose_settings_page_appearance)
val contentDiscoveryPage = stringResource(Res.string.compose_settings_page_content_discovery)
val downloadsPage = stringResource(Res.string.compose_settings_root_downloads_title)
val playbackPage = stringResource(Res.string.compose_settings_page_playback)
val integrationsPage = stringResource(Res.string.compose_settings_page_integrations)
val notificationsPage = stringResource(Res.string.compose_settings_page_notifications)
val supportersPage = stringResource(Res.string.compose_settings_page_supporters_contributors)
val homeLayoutPage = stringResource(Res.string.compose_settings_page_homescreen)
val detailPage = stringResource(Res.string.compose_settings_page_meta_screen)
val continueWatchingPage = stringResource(Res.string.compose_settings_page_continue_watching)
val posterStylePage = stringResource(Res.string.compose_settings_page_poster_customization)
val addonsPage = stringResource(Res.string.compose_settings_page_addons)
val pluginsPage = stringResource(Res.string.compose_settings_page_plugins)
val collectionsPage = stringResource(Res.string.collections_header)
val tmdbPage = stringResource(Res.string.compose_settings_page_tmdb_enrichment)
val mdbListPage = stringResource(Res.string.compose_settings_page_mdblist_ratings)
val entries = mutableListOf<SettingsSearchEntry>()
fun add(
key: String,
title: String,
description: String = "",
page: String = title,
section: String = "",
category: String = generalCategory,
icon: ImageVector,
target: SettingsSearchTarget,
) {
entries += SettingsSearchEntry(
key = key,
title = title,
description = description,
page = page,
section = section,
category = category,
icon = icon,
target = target,
)
}
fun addPage(
page: SettingsPage,
key: String,
title: String,
description: String,
category: String = generalCategory,
icon: ImageVector,
) {
add(
key = key,
title = title,
description = description,
page = title,
category = category,
icon = icon,
target = SettingsSearchTarget.Page(page),
)
}
fun addRow(
page: SettingsPage,
key: String,
title: String,
description: String = "",
pageLabel: String,
section: String,
category: String = generalCategory,
icon: ImageVector,
) {
add(
key = key,
title = title,
description = description,
page = pageLabel,
section = section,
category = category,
icon = icon,
target = SettingsSearchTarget.Page(page),
)
}
if (switchProfileAvailable) {
add(
key = "switch-profile",
title = stringResource(Res.string.compose_settings_root_switch_profile_title),
description = stringResource(Res.string.compose_settings_root_switch_profile_description),
page = accountPage,
section = stringResource(Res.string.compose_settings_root_account_section),
category = accountCategory,
icon = Icons.Rounded.People,
target = SettingsSearchTarget.SwitchProfile,
)
}
addPage(
page = SettingsPage.Account,
key = "account",
title = accountPage,
description = stringResource(Res.string.compose_settings_root_account_description),
category = accountCategory,
icon = Icons.Rounded.AccountCircle,
)
addPage(
page = SettingsPage.TraktAuthentication,
key = "trakt",
title = traktPage,
description = stringResource(Res.string.compose_settings_root_trakt_description),
category = accountCategory,
icon = Icons.Rounded.Link,
)
addPage(
page = SettingsPage.Appearance,
key = "layout",
title = layoutPage,
description = stringResource(Res.string.compose_settings_root_appearance_description),
icon = Icons.Rounded.Palette,
)
addPage(
page = SettingsPage.ContentDiscovery,
key = "content-discovery",
title = contentDiscoveryPage,
description = stringResource(Res.string.compose_settings_root_content_discovery_description),
icon = Icons.Rounded.Extension,
)
add(
key = "downloads",
title = downloadsPage,
description = stringResource(Res.string.compose_settings_root_downloads_description),
category = generalCategory,
icon = Icons.Rounded.CloudDownload,
target = SettingsSearchTarget.Downloads,
)
addPage(
page = SettingsPage.Playback,
key = "playback",
title = playbackPage,
description = stringResource(Res.string.settings_playback_subtitle),
icon = Icons.Rounded.PlayArrow,
)
addPage(
page = SettingsPage.Integrations,
key = "integrations",
title = integrationsPage,
description = stringResource(Res.string.compose_settings_root_integrations_description),
icon = Icons.Rounded.Link,
)
addPage(
page = SettingsPage.Notifications,
key = "notifications",
title = notificationsPage,
description = stringResource(Res.string.compose_settings_root_notifications_description),
icon = Icons.Rounded.Notifications,
)
addPage(
page = SettingsPage.SupportersContributors,
key = "supporters",
title = supportersPage,
description = stringResource(Res.string.about_supporters_contributors_subtitle),
category = aboutCategory,
icon = Icons.Rounded.Favorite,
)
if (checkForUpdatesAvailable) {
add(
key = "check-updates",
title = stringResource(Res.string.compose_settings_root_check_updates_title),
description = stringResource(Res.string.compose_settings_root_check_updates_description),
page = supportersPage,
section = stringResource(Res.string.compose_settings_root_about_section),
category = aboutCategory,
icon = Icons.Rounded.CloudDownload,
target = SettingsSearchTarget.CheckForUpdates,
)
}
addRow(
page = SettingsPage.Account,
key = "account-status",
title = stringResource(Res.string.settings_account_status),
pageLabel = accountPage,
section = accountPage,
category = accountCategory,
icon = Icons.Rounded.AccountCircle,
)
addRow(
page = SettingsPage.Account,
key = "account-sign-out",
title = stringResource(Res.string.settings_account_sign_out),
pageLabel = accountPage,
section = accountPage,
category = accountCategory,
icon = Icons.Rounded.AccountCircle,
)
addRow(
page = SettingsPage.Appearance,
key = "theme",
title = stringResource(Res.string.settings_appearance_section_theme),
pageLabel = layoutPage,
section = stringResource(Res.string.settings_appearance_section_theme),
icon = Icons.Rounded.Palette,
)
addRow(
page = SettingsPage.Appearance,
key = "amoled",
title = stringResource(Res.string.settings_appearance_amoled_black),
description = stringResource(Res.string.settings_appearance_amoled_description),
pageLabel = layoutPage,
section = stringResource(Res.string.settings_appearance_section_display),
icon = Icons.Rounded.Palette,
)
if (liquidGlassNativeTabBarSupported) {
addRow(
page = SettingsPage.Appearance,
key = "liquid-glass",
title = stringResource(Res.string.settings_appearance_liquid_glass),
description = stringResource(Res.string.settings_appearance_liquid_glass_description),
pageLabel = layoutPage,
section = stringResource(Res.string.settings_appearance_section_display),
icon = Icons.Rounded.Palette,
)
}
addRow(
page = SettingsPage.Appearance,
key = "app-language",
title = stringResource(Res.string.settings_appearance_app_language),
pageLabel = layoutPage,
section = stringResource(Res.string.settings_appearance_section_display),
icon = Icons.Rounded.Language,
)
addPage(
page = SettingsPage.ContinueWatching,
key = "continue-watching",
title = continueWatchingPage,
description = stringResource(Res.string.settings_appearance_continue_watching_description),
icon = Icons.Rounded.Style,
)
addPage(
page = SettingsPage.PosterCustomization,
key = "poster-card-style",
title = posterStylePage,
description = stringResource(Res.string.settings_appearance_poster_customization_description),
icon = Icons.Rounded.Tune,
)
addPage(
page = SettingsPage.Addons,
key = "addons",
title = addonsPage,
description = stringResource(Res.string.settings_content_discovery_addons_description),
icon = Icons.Rounded.Extension,
)
if (pluginsEnabled) {
addPage(
page = SettingsPage.Plugins,
key = "plugins",
title = pluginsPage,
description = stringResource(Res.string.settings_content_discovery_plugins_description),
icon = Icons.Rounded.Hub,
)
}
addPage(
page = SettingsPage.Homescreen,
key = "home-layout",
title = homeLayoutPage,
description = stringResource(Res.string.settings_content_discovery_homescreen_description),
icon = Icons.Rounded.Home,
)
addPage(
page = SettingsPage.MetaScreen,
key = "detail-page",
title = detailPage,
description = stringResource(Res.string.settings_content_discovery_meta_screen_description),
icon = Icons.Rounded.Tune,
)
add(
key = "collections",
title = collectionsPage,
description = stringResource(Res.string.settings_content_discovery_collections_description),
page = contentDiscoveryPage,
section = stringResource(Res.string.settings_content_discovery_section_home),
category = generalCategory,
icon = Icons.Rounded.CollectionsBookmark,
target = SettingsSearchTarget.Collections,
)
val playbackPlayer = stringResource(Res.string.settings_playback_section_player)
val playbackSubtitleAudio = stringResource(Res.string.settings_playback_section_subtitle_audio)
val playbackStreamSelection = stringResource(Res.string.settings_playback_section_stream_selection)
val playbackStreamAutoPlay = stringResource(Res.string.settings_playback_section_stream_auto_play)
val playbackDecoder = stringResource(Res.string.settings_playback_section_decoder)
val playbackSubtitleRendering = stringResource(Res.string.settings_playback_section_subtitle_rendering)
val playbackSkipSegments = stringResource(Res.string.settings_playback_section_skip_segments)
val playbackNextEpisode = stringResource(Res.string.settings_playback_section_next_episode)
addPlaybackRows(
addRow = ::addRow,
pageLabel = playbackPage,
section = playbackPlayer,
icon = Icons.Rounded.PlayArrow,
rows = listOf(
PlaybackSearchRow(
"loading-overlay",
stringResource(Res.string.settings_playback_show_loading_overlay),
stringResource(Res.string.settings_playback_show_loading_overlay_description),
),
PlaybackSearchRow(
"hold-to-speed",
stringResource(Res.string.settings_playback_hold_to_speed),
stringResource(Res.string.settings_playback_hold_to_speed_description),
),
PlaybackSearchRow("hold-speed", stringResource(Res.string.settings_playback_hold_speed)),
),
)
addPlaybackRows(
addRow = ::addRow,
pageLabel = playbackPage,
section = playbackSubtitleAudio,
icon = Icons.Rounded.PlayArrow,
rows = listOf(
PlaybackSearchRow("preferred-audio", stringResource(Res.string.settings_playback_preferred_audio_language)),
PlaybackSearchRow("secondary-audio", stringResource(Res.string.settings_playback_secondary_audio_language)),
PlaybackSearchRow("preferred-subtitles", stringResource(Res.string.settings_playback_preferred_subtitle_language)),
PlaybackSearchRow("secondary-subtitles", stringResource(Res.string.settings_playback_secondary_subtitle_language)),
),
)
addPlaybackRows(
addRow = ::addRow,
pageLabel = playbackPage,
section = playbackStreamSelection,
icon = Icons.Rounded.PlayArrow,
rows = listOf(
PlaybackSearchRow(
"reuse-last-link",
stringResource(Res.string.settings_playback_reuse_last_link),
stringResource(Res.string.settings_playback_reuse_last_link_description),
),
PlaybackSearchRow("last-link-cache", stringResource(Res.string.settings_playback_last_link_cache_duration)),
),
)
addPlaybackRows(
addRow = ::addRow,
pageLabel = playbackPage,
section = playbackStreamAutoPlay,
icon = Icons.Rounded.PlayArrow,
rows = buildList {
add(PlaybackSearchRow("stream-mode", stringResource(Res.string.settings_playback_stream_selection_mode)))
add(PlaybackSearchRow("regex-pattern", stringResource(Res.string.settings_playback_regex_pattern)))
add(PlaybackSearchRow("stream-timeout", stringResource(Res.string.settings_playback_stream_timeout), stringResource(Res.string.settings_playback_stream_timeout_description)))
add(PlaybackSearchRow("source-scope", stringResource(Res.string.settings_playback_source_scope)))
add(PlaybackSearchRow("allowed-addons", stringResource(Res.string.settings_playback_allowed_addons)))
if (pluginsEnabled) add(PlaybackSearchRow("allowed-plugins", stringResource(Res.string.settings_playback_allowed_plugins)))
},
)
if (!isIos) {
addPlaybackRows(
addRow = ::addRow,
pageLabel = playbackPage,
section = playbackDecoder,
icon = Icons.Rounded.PlayArrow,
rows = listOf(
PlaybackSearchRow("decoder-priority", stringResource(Res.string.settings_playback_decoder_priority)),
PlaybackSearchRow("dv7-hevc", stringResource(Res.string.settings_playback_map_dv7_to_hevc), stringResource(Res.string.settings_playback_map_dv7_to_hevc_description)),
PlaybackSearchRow("tunneled-playback", stringResource(Res.string.settings_playback_tunneled_playback), stringResource(Res.string.settings_playback_tunneled_playback_description)),
),
)
addPlaybackRows(
addRow = ::addRow,
pageLabel = playbackPage,
section = playbackSubtitleRendering,
icon = Icons.Rounded.PlayArrow,
rows = listOf(
PlaybackSearchRow("libass", stringResource(Res.string.settings_playback_enable_libass), stringResource(Res.string.settings_playback_enable_libass_description)),
PlaybackSearchRow("libass-render", stringResource(Res.string.settings_playback_render_type)),
),
)
}
addPlaybackRows(
addRow = ::addRow,
pageLabel = playbackPage,
section = playbackSkipSegments,
icon = Icons.Rounded.PlayArrow,
rows = listOf(
PlaybackSearchRow("skip-intro", stringResource(Res.string.settings_playback_skip_intro_outro_recap), stringResource(Res.string.settings_playback_skip_intro_outro_recap_description)),
PlaybackSearchRow("anime-skip", stringResource(Res.string.settings_playback_anime_skip), stringResource(Res.string.settings_playback_anime_skip_description)),
PlaybackSearchRow("anime-skip-client", stringResource(Res.string.settings_playback_anime_skip_client_id), stringResource(Res.string.settings_playback_anime_skip_client_id_description)),
PlaybackSearchRow("intro-submit", stringResource(Res.string.settings_playback_intro_submit_enabled), stringResource(Res.string.settings_playback_intro_submit_enabled_description)),
PlaybackSearchRow("introdb-key", stringResource(Res.string.settings_playback_introdb_api_key), stringResource(Res.string.settings_playback_introdb_api_key_description)),
),
)
addPlaybackRows(
addRow = ::addRow,
pageLabel = playbackPage,
section = playbackNextEpisode,
icon = Icons.Rounded.PlayArrow,
rows = listOf(
PlaybackSearchRow("auto-play-next", stringResource(Res.string.settings_playback_auto_play_next_episode), stringResource(Res.string.settings_playback_auto_play_next_episode_description)),
PlaybackSearchRow("prefer-binge", stringResource(Res.string.settings_playback_prefer_binge_group), stringResource(Res.string.settings_playback_prefer_binge_group_description)),
PlaybackSearchRow("threshold-mode", stringResource(Res.string.settings_playback_threshold_mode)),
PlaybackSearchRow("threshold-percent", stringResource(Res.string.settings_playback_threshold_percentage), stringResource(Res.string.settings_playback_threshold_percentage_description)),
PlaybackSearchRow("threshold-minutes", stringResource(Res.string.settings_playback_minutes_before_end), stringResource(Res.string.settings_playback_minutes_before_end_description)),
),
)
addContinueWatchingRows(
addRow = ::addRow,
pageLabel = continueWatchingPage,
section = stringResource(Res.string.settings_continue_watching_section_visibility),
icon = Icons.Rounded.Style,
rows = listOf(
PlaybackSearchRow(
"show-continue-watching",
stringResource(Res.string.settings_continue_watching_show_title),
stringResource(Res.string.settings_continue_watching_show_description),
),
),
)
addContinueWatchingRows(
addRow = ::addRow,
pageLabel = continueWatchingPage,
section = stringResource(Res.string.settings_continue_watching_section_up_next_behavior),
icon = Icons.Rounded.Style,
rows = listOf(
PlaybackSearchRow("episode-thumbnails", stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_title), stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_description)),
PlaybackSearchRow("up-next", stringResource(Res.string.settings_continue_watching_up_next_title), stringResource(Res.string.settings_continue_watching_up_next_description)),
PlaybackSearchRow("unaired-next-up", stringResource(Res.string.settings_continue_watching_show_unaired_next_up_title), stringResource(Res.string.settings_continue_watching_show_unaired_next_up_description)),
PlaybackSearchRow("blur-next-up", stringResource(Res.string.settings_continue_watching_blur_next_up_title), stringResource(Res.string.settings_continue_watching_blur_next_up_description)),
),
)
addContinueWatchingRows(
addRow = ::addRow,
pageLabel = continueWatchingPage,
section = stringResource(Res.string.settings_continue_watching_section_on_launch),
icon = Icons.Rounded.Style,
rows = listOf(
PlaybackSearchRow("resume-prompt", stringResource(Res.string.settings_continue_watching_resume_prompt_title), stringResource(Res.string.settings_continue_watching_resume_prompt_description)),
),
)
val posterSection = stringResource(Res.string.settings_poster_card_style)
listOf(
PlaybackSearchRow("poster-width", stringResource(Res.string.settings_poster_card_width)),
PlaybackSearchRow("poster-radius", stringResource(Res.string.settings_poster_card_radius)),
PlaybackSearchRow("poster-landscape", stringResource(Res.string.settings_poster_landscape_mode)),
PlaybackSearchRow("poster-hide-labels", stringResource(Res.string.settings_poster_hide_labels)),
).forEach { row ->
addRow(
page = SettingsPage.PosterCustomization,
key = "poster-${row.key}",
title = row.title,
description = row.description,
pageLabel = posterStylePage,
section = posterSection,
icon = Icons.Rounded.Tune,
)
}
val homeLayoutSection = stringResource(Res.string.settings_homescreen_section_hero)
listOf(
PlaybackSearchRow("home-hero", stringResource(Res.string.settings_homescreen_show_hero), stringResource(Res.string.settings_homescreen_show_hero_description)),
PlaybackSearchRow("home-hide-unreleased", stringResource(Res.string.layout_hide_unreleased), stringResource(Res.string.layout_hide_unreleased_sub)),
PlaybackSearchRow("home-hero-sources", stringResource(Res.string.settings_homescreen_section_hero_sources)),
PlaybackSearchRow("home-catalogs", stringResource(Res.string.settings_homescreen_section_catalogs)),
).forEach { row ->
addRow(
page = SettingsPage.Homescreen,
key = row.key,
title = row.title,
description = row.description,
pageLabel = homeLayoutPage,
section = homeLayoutSection,
icon = Icons.Rounded.Home,
)
}
val detailAppearanceSection = stringResource(Res.string.settings_meta_section_appearance)
listOf(
PlaybackSearchRow("meta-cinematic", stringResource(Res.string.settings_meta_cinematic_background), stringResource(Res.string.settings_meta_cinematic_background_description)),
PlaybackSearchRow("meta-tabs", stringResource(Res.string.settings_meta_tab_layout), stringResource(Res.string.settings_meta_tab_layout_description)),
PlaybackSearchRow("meta-episode-cards", stringResource(Res.string.settings_meta_episode_cards), stringResource(Res.string.settings_meta_episode_cards_description)),
PlaybackSearchRow("meta-blur-episodes", stringResource(Res.string.settings_meta_blur_unwatched_episodes), stringResource(Res.string.settings_meta_blur_unwatched_episodes_description)),
).forEach { row ->
addRow(
page = SettingsPage.MetaScreen,
key = row.key,
title = row.title,
description = row.description,
pageLabel = detailPage,
section = detailAppearanceSection,
icon = Icons.Rounded.Tune,
)
}
val detailSectionsSection = stringResource(Res.string.settings_meta_section_sections)
listOf(
PlaybackSearchRow("meta-overview", stringResource(Res.string.settings_meta_overview), stringResource(Res.string.settings_meta_overview_description)),
PlaybackSearchRow("meta-actions", stringResource(Res.string.settings_meta_actions), stringResource(Res.string.settings_meta_actions_description)),
PlaybackSearchRow("meta-details", stringResource(Res.string.settings_meta_details), stringResource(Res.string.settings_meta_details_description)),
PlaybackSearchRow("meta-trailers", stringResource(Res.string.settings_meta_trailers), stringResource(Res.string.settings_meta_trailers_description)),
PlaybackSearchRow("meta-cast", stringResource(Res.string.settings_meta_cast), stringResource(Res.string.settings_meta_cast_description)),
PlaybackSearchRow("meta-episodes", stringResource(Res.string.settings_meta_episodes), stringResource(Res.string.settings_meta_episodes_description)),
PlaybackSearchRow("meta-production", stringResource(Res.string.settings_meta_production), stringResource(Res.string.settings_meta_production_description)),
PlaybackSearchRow("meta-more-like-this", stringResource(Res.string.settings_meta_more_like_this), stringResource(Res.string.settings_meta_more_like_this_description)),
PlaybackSearchRow("meta-collection", stringResource(Res.string.settings_meta_collection), stringResource(Res.string.settings_meta_collection_description)),
PlaybackSearchRow("meta-comments", stringResource(Res.string.settings_meta_comments), stringResource(Res.string.settings_meta_comments_description)),
).forEach { row ->
addRow(
page = SettingsPage.MetaScreen,
key = row.key,
title = row.title,
description = row.description,
pageLabel = detailPage,
section = detailSectionsSection,
icon = Icons.Rounded.Tune,
)
}
addPage(
page = SettingsPage.TmdbEnrichment,
key = "tmdb",
title = tmdbPage,
description = stringResource(Res.string.settings_integrations_tmdb_description),
icon = Icons.Rounded.Link,
)
addPage(
page = SettingsPage.MdbListRatings,
key = "mdblist",
title = mdbListPage,
description = stringResource(Res.string.settings_integrations_mdblist_description),
icon = Icons.Rounded.Link,
)
val tmdbModulesSection = stringResource(Res.string.settings_tmdb_section_modules)
listOf(
PlaybackSearchRow("tmdb-enable", stringResource(Res.string.settings_tmdb_enable_enrichment), stringResource(Res.string.settings_tmdb_enable_enrichment_description), stringResource(Res.string.settings_tmdb_section_title)),
PlaybackSearchRow("tmdb-api-key", stringResource(Res.string.settings_tmdb_personal_api_key), "", stringResource(Res.string.settings_tmdb_section_credentials)),
PlaybackSearchRow("tmdb-language", stringResource(Res.string.settings_tmdb_preferred_language), stringResource(Res.string.settings_tmdb_preferred_language_description), stringResource(Res.string.settings_tmdb_section_localization)),
PlaybackSearchRow("tmdb-trailers", stringResource(Res.string.settings_tmdb_module_trailers), stringResource(Res.string.settings_tmdb_module_trailers_description), tmdbModulesSection),
PlaybackSearchRow("tmdb-artwork", stringResource(Res.string.settings_tmdb_module_artwork), stringResource(Res.string.settings_tmdb_module_artwork_description), tmdbModulesSection),
PlaybackSearchRow("tmdb-basic-info", stringResource(Res.string.settings_tmdb_module_basic_info), stringResource(Res.string.settings_tmdb_module_basic_info_description), tmdbModulesSection),
PlaybackSearchRow("tmdb-details", stringResource(Res.string.settings_tmdb_module_details), stringResource(Res.string.settings_tmdb_module_details_description), tmdbModulesSection),
PlaybackSearchRow("tmdb-credits", stringResource(Res.string.settings_tmdb_module_credits), stringResource(Res.string.settings_tmdb_module_credits_description), tmdbModulesSection),
PlaybackSearchRow("tmdb-companies", stringResource(Res.string.settings_tmdb_module_production_companies), stringResource(Res.string.settings_tmdb_module_production_companies_description), tmdbModulesSection),
PlaybackSearchRow("tmdb-networks", stringResource(Res.string.settings_tmdb_module_networks), stringResource(Res.string.settings_tmdb_module_networks_description), tmdbModulesSection),
PlaybackSearchRow("tmdb-episodes", stringResource(Res.string.settings_tmdb_module_episodes), stringResource(Res.string.settings_tmdb_module_episodes_description), tmdbModulesSection),
PlaybackSearchRow("tmdb-season-posters", stringResource(Res.string.settings_tmdb_module_season_posters), stringResource(Res.string.settings_tmdb_module_season_posters_description), tmdbModulesSection),
PlaybackSearchRow("tmdb-more-like-this", stringResource(Res.string.settings_tmdb_module_more_like_this), stringResource(Res.string.settings_tmdb_module_more_like_this_description), tmdbModulesSection),
PlaybackSearchRow("tmdb-collections", stringResource(Res.string.settings_tmdb_module_collections), stringResource(Res.string.settings_tmdb_module_collections_description), tmdbModulesSection),
).forEach { row ->
addRow(
page = SettingsPage.TmdbEnrichment,
key = row.key,
title = row.title,
description = row.description,
pageLabel = tmdbPage,
section = row.sectionOverride ?: tmdbModulesSection,
icon = Icons.Rounded.Link,
)
}
listOf(
PlaybackSearchRow("mdb-enable", stringResource(Res.string.settings_mdb_enable_ratings), stringResource(Res.string.settings_mdb_enable_ratings_description), stringResource(Res.string.settings_mdb_section_title)),
PlaybackSearchRow("mdb-api-key", stringResource(Res.string.settings_mdb_api_key_title), stringResource(Res.string.settings_mdb_api_key_description), stringResource(Res.string.settings_mdb_section_api_key)),
PlaybackSearchRow("mdb-imdb", stringResource(Res.string.source_imdb), "", stringResource(Res.string.settings_mdb_section_rating_providers)),
PlaybackSearchRow("mdb-tmdb", stringResource(Res.string.source_tmdb), "", stringResource(Res.string.settings_mdb_section_rating_providers)),
PlaybackSearchRow("mdb-tomatoes", stringResource(Res.string.source_rotten_tomatoes), "", stringResource(Res.string.settings_mdb_section_rating_providers)),
PlaybackSearchRow("mdb-metacritic", stringResource(Res.string.source_metacritic), "", stringResource(Res.string.settings_mdb_section_rating_providers)),
PlaybackSearchRow("mdb-trakt", stringResource(Res.string.source_trakt), "", stringResource(Res.string.settings_mdb_section_rating_providers)),
PlaybackSearchRow("mdb-letterboxd", stringResource(Res.string.source_letterboxd), "", stringResource(Res.string.settings_mdb_section_rating_providers)),
PlaybackSearchRow("mdb-audience", stringResource(Res.string.source_audience_score), "", stringResource(Res.string.settings_mdb_section_rating_providers)),
).forEach { row ->
addRow(
page = SettingsPage.MdbListRatings,
key = row.key,
title = row.title,
description = row.description,
pageLabel = mdbListPage,
section = row.sectionOverride ?: stringResource(Res.string.settings_mdb_section_title),
icon = Icons.Rounded.Link,
)
}
val notificationsAlerts = stringResource(Res.string.settings_notifications_section_alerts)
addRow(
page = SettingsPage.Notifications,
key = "episode-release-alerts",
title = stringResource(Res.string.settings_notifications_episode_release_alerts),
description = stringResource(Res.string.settings_notifications_episode_release_alerts_description),
pageLabel = notificationsPage,
section = notificationsAlerts,
icon = Icons.Rounded.Notifications,
)
addRow(
page = SettingsPage.Notifications,
key = "notification-test",
title = stringResource(Res.string.settings_notifications_test_title),
pageLabel = notificationsPage,
section = stringResource(Res.string.settings_notifications_section_test),
icon = Icons.Rounded.Notifications,
)
addRow(
page = SettingsPage.TraktAuthentication,
key = "trakt-authentication",
title = stringResource(Res.string.settings_trakt_authentication),
description = stringResource(Res.string.settings_trakt_intro_description),
pageLabel = traktPage,
section = stringResource(Res.string.settings_trakt_authentication),
category = accountCategory,
icon = Icons.Rounded.Link,
)
listOf(
PlaybackSearchRow("trakt-library-source", stringResource(Res.string.trakt_library_source_title), stringResource(Res.string.trakt_library_source_subtitle)),
PlaybackSearchRow("trakt-watch-progress", stringResource(Res.string.trakt_watch_progress_title), stringResource(Res.string.trakt_watch_progress_subtitle)),
PlaybackSearchRow("trakt-continue-watching-window", stringResource(Res.string.trakt_continue_watching_window), stringResource(Res.string.trakt_continue_watching_subtitle)),
PlaybackSearchRow("trakt-comments", stringResource(Res.string.settings_trakt_comments), stringResource(Res.string.settings_trakt_comments_description)),
).forEach { row ->
addRow(
page = SettingsPage.TraktAuthentication,
key = row.key,
title = row.title,
description = row.description,
pageLabel = traktPage,
section = stringResource(Res.string.settings_trakt_features),
category = accountCategory,
icon = Icons.Rounded.Link,
)
}
return entries
}
private data class PlaybackSearchRow(
val key: String,
val title: String,
val description: String = "",
val sectionOverride: String? = null,
)
private fun addPlaybackRows(
addRow: (
page: SettingsPage,
key: String,
title: String,
description: String,
pageLabel: String,
section: String,
category: String,
icon: ImageVector,
) -> Unit,
pageLabel: String,
section: String,
icon: ImageVector,
rows: List<PlaybackSearchRow>,
) {
rows.forEach { row ->
addRow(
SettingsPage.Playback,
"playback-${row.key}",
row.title,
row.description,
pageLabel,
section,
"",
icon,
)
}
}
private fun addContinueWatchingRows(
addRow: (
page: SettingsPage,
key: String,
title: String,
description: String,
pageLabel: String,
section: String,
category: String,
icon: ImageVector,
) -> Unit,
pageLabel: String,
section: String,
icon: ImageVector,
rows: List<PlaybackSearchRow>,
) {
rows.forEach { row ->
addRow(
SettingsPage.ContinueWatching,
"continue-watching-${row.key}",
row.title,
row.description,
pageLabel,
section,
"",
icon,
)
}
}
internal fun LazyListScope.settingsSearchRootContent(
query: String,
entries: List<SettingsSearchEntry>,
isTablet: Boolean,
showSearchField: Boolean,
animateSearchField: Boolean,
onQueryChange: (String) -> Unit,
onTargetClick: (SettingsSearchTarget) -> Unit,
) {
if (showSearchField || query.isNotBlank()) {
item(key = "settings-search-field") {
SettingsSearchRevealItem(animate = animateSearchField) {
SettingsSearchField(
query = query,
onQueryChange = onQueryChange,
)
}
}
}
if (query.isBlank()) return
val results = settingsSearchResults(
query = query,
entries = entries,
)
item(key = "settings-search-results") {
if (results.isEmpty()) {
SettingsSearchEmptyState(isTablet = isTablet)
} else {
SettingsSection(
title = stringResource(Res.string.settings_search_results_section),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
results.forEachIndexed { index, entry ->
if (index > 0) {
SettingsGroupDivider(isTablet = isTablet)
}
SettingsNavigationRow(
title = entry.title,
description = entry.resultDescription(),
icon = entry.icon,
isTablet = isTablet,
onClick = { onTargetClick(entry.target) },
)
}
}
}
}
}
}
@Composable
private fun SettingsSearchRevealItem(
animate: Boolean,
content: @Composable () -> Unit,
) {
if (!animate) {
content()
return
}
val visibleState = remember {
MutableTransitionState(false).apply {
targetState = true
}
}
AnimatedVisibility(
visibleState = visibleState,
enter = expandVertically(
animationSpec = tween(durationMillis = 220),
expandFrom = Alignment.Top,
) + fadeIn(
animationSpec = tween(durationMillis = 180),
) + slideInVertically(
animationSpec = tween(durationMillis = 220),
initialOffsetY = { -it / 4 },
),
) {
content()
}
}
@Composable
private fun SettingsSearchField(
query: String,
onQueryChange: (String) -> Unit,
) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(14.dp),
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailingIcon = if (query.isNotBlank()) {
{
IconButton(onClick = { onQueryChange("") }) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = stringResource(Res.string.compose_search_clear),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
null
},
placeholder = {
Text(
text = stringResource(Res.string.settings_search_placeholder),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge,
)
},
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.outline,
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
}
@Composable
private fun SettingsSearchEmptyState(isTablet: Boolean) {
SettingsSection(
title = stringResource(Res.string.settings_search_results_section),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = 18.dp),
) {
Text(
text = stringResource(Res.string.settings_search_empty),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
}
}
}
}
private fun settingsSearchResults(
query: String,
entries: List<SettingsSearchEntry>,
): List<SettingsSearchEntry> {
val terms = query
.trim()
.lowercase()
.split(Regex("\\s+"))
.filter { it.isNotBlank() }
if (terms.isEmpty()) return emptyList()
return entries.filter { entry ->
terms.all { term -> entry.searchableText.contains(term) }
}
}
private fun SettingsSearchEntry.resultDescription(): String {
return description.ifBlank { contextLabel }
}

View file

@ -0,0 +1,70 @@
package com.nuvio.app.features.trakt
private val TraktIsoDateTimeRegex = Regex(
"""^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})$""",
)
internal fun parseTraktIsoDateTimeToEpochMs(value: String): Long? {
val match = TraktIsoDateTimeRegex.matchEntire(value.trim()) ?: return null
val year = match.groupValues[1].toIntOrNull() ?: return null
val month = match.groupValues[2].toIntOrNull()?.takeIf { it in 1..12 } ?: return null
val day = match.groupValues[3].toIntOrNull() ?: return null
val hour = match.groupValues[4].toIntOrNull()?.takeIf { it in 0..23 } ?: return null
val minute = match.groupValues[5].toIntOrNull()?.takeIf { it in 0..59 } ?: return null
val second = match.groupValues[6].toIntOrNull()?.takeIf { it in 0..59 } ?: return null
if (day !in 1..daysInMonth(year, month)) return null
val millisecond = match.groupValues[7]
.takeIf { it.isNotEmpty() }
?.padEnd(3, '0')
?.take(3)
?.toIntOrNull()
?: 0
val offsetMs = parseOffsetMs(match.groupValues[8]) ?: return null
return isoEpochDay(year, month, day) * MillisPerDay +
hour * MillisPerHour +
minute * MillisPerMinute +
second * MillisPerSecond +
millisecond -
offsetMs
}
private fun parseOffsetMs(value: String): Long? {
if (value == "Z") return 0L
val sign = when (value.firstOrNull()) {
'+' -> 1L
'-' -> -1L
else -> return null
}
val digits = value.drop(1).replace(":", "")
if (digits.length != 4) return null
val hours = digits.take(2).toIntOrNull()?.takeIf { it in 0..23 } ?: return null
val minutes = digits.drop(2).toIntOrNull()?.takeIf { it in 0..59 } ?: return null
return sign * ((hours * MillisPerHour) + (minutes * MillisPerMinute))
}
private fun isoEpochDay(year: Int, month: Int, day: Int): Long {
val adjustedYear = year.toLong() - if (month <= 2) 1L else 0L
val era = if (adjustedYear >= 0L) adjustedYear / 400L else (adjustedYear - 399L) / 400L
val yearOfEra = adjustedYear - era * 400L
val adjustedMonth = month.toLong() + if (month > 2) -3L else 9L
val dayOfYear = (153L * adjustedMonth + 2L) / 5L + day - 1L
val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear
return era * 146_097L + dayOfEra - 719_468L
}
private fun daysInMonth(year: Int, month: Int): Int =
when (month) {
2 -> if (isLeapYear(year)) 29 else 28
4, 6, 9, 11 -> 30
else -> 31
}
private fun isLeapYear(year: Int): Boolean =
year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
private const val MillisPerSecond = 1_000L
private const val MillisPerMinute = 60L * MillisPerSecond
private const val MillisPerHour = 60L * MillisPerMinute
private const val MillisPerDay = 24L * MillisPerHour

View file

@ -3,8 +3,13 @@ package com.nuvio.app.features.collection
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
@ -178,4 +183,69 @@ class CollectionSourceSerializationTest {
assertTrue(merged.contains(""""customField":"keep-me"""")) assertTrue(merged.contains(""""customField":"keep-me""""))
assertTrue(merged.contains(""""traktListId":123456""")) assertTrue(merged.contains(""""traktListId":123456"""))
} }
@Test
fun mobileGifToggleDoesNotEnterCollectionJsonOrOverwriteTvGifToggle() {
val raw = json.parseToJsonElement(
"""
[
{
"id": "collection-1",
"title": "Favorites",
"folders": [
{
"id": "folder-1",
"title": "Movies",
"coverImageUrl": "https://example.com/poster.jpg",
"focusGifUrl": "https://example.com/focus.gif",
"focusGifEnabled": true
}
]
}
]
""".trimIndent(),
)
val collection = json.decodeFromString<List<Collection>>(raw.toString()).single()
val mobileDisabled = collection.copy(
folders = collection.folders.map { folder ->
folder.copy(mobileFocusGifEnabled = false)
},
)
val merged = CollectionJsonPreserver.merge(json, raw, listOf(mobileDisabled))
val mergedFolder = merged
.single()
.jsonObject["folders"]!!
.jsonArray
.single()
.jsonObject
assertTrue(mergedFolder["focusGifEnabled"]!!.jsonPrimitive.boolean)
assertTrue(mergedFolder["mobileFocusGifEnabled"] == null)
}
@Test
fun mobileGifToggleDefaultsIndependentOfTvGifToggle() {
val payload = """
[
{
"id": "collection-1",
"title": "Favorites",
"folders": [
{
"id": "folder-1",
"title": "Movies",
"focusGifUrl": "https://example.com/focus.gif",
"focusGifEnabled": false
}
]
}
]
""".trimIndent()
val folder = json.decodeFromString<List<Collection>>(payload).single().folders.single()
assertFalse(folder.focusGifEnabled)
assertTrue(folder.mobileFocusGifEnabled)
}
} }

View file

@ -46,6 +46,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"trakt_auth_payload", "trakt_auth_payload",
"trakt_library_payload", "trakt_library_payload",
"trakt_settings_payload", "trakt_settings_payload",
"collection_mobile_settings_payload",
"collections_payload", "collections_payload",
) )

View file

@ -0,0 +1,15 @@
package com.nuvio.app.features.collection
import com.nuvio.app.core.storage.ProfileScopedKey
import platform.Foundation.NSUserDefaults
actual object CollectionMobileSettingsStorage {
private const val payloadKey = "collection_mobile_settings_payload"
actual fun loadPayload(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey))
actual fun savePayload(payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey))
}
}

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.home.components
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -51,6 +52,16 @@ private data class ExpandedGifFrames(
val tickCentiseconds: Int, val tickCentiseconds: Int,
) )
private class GifImageViewHolder {
var imageView: UIImageView? = null
fun clear() {
imageView?.stopAnimating()
imageView?.image = null
imageView = null
}
}
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
@Composable @Composable
internal actual fun CollectionCardRemoteImage( internal actual fun CollectionCardRemoteImage(
@ -76,6 +87,13 @@ internal actual fun CollectionCardRemoteImage(
gifImage = loadGifImage(imageUrl) gifImage = loadGifImage(imageUrl)
} }
val imageViewHolder = remember(imageUrl) { GifImageViewHolder() }
DisposableEffect(imageUrl) {
onDispose {
imageViewHolder.clear()
}
}
UIKitView( UIKitView(
modifier = modifier, modifier = modifier,
factory = { factory = {
@ -83,19 +101,31 @@ internal actual fun CollectionCardRemoteImage(
contentMode = UIViewContentMode.UIViewContentModeScaleAspectFill contentMode = UIViewContentMode.UIViewContentModeScaleAspectFill
clipsToBounds = true clipsToBounds = true
userInteractionEnabled = false userInteractionEnabled = false
image = gifImage
tag = imageUrl.hashCode().toLong() tag = imageUrl.hashCode().toLong()
imageViewHolder.imageView = this
updateGifImage(gifImage)
} }
}, },
update = { imageView -> update = { imageView ->
imageViewHolder.imageView = imageView
if (imageView.tag != imageUrl.hashCode().toLong()) { if (imageView.tag != imageUrl.hashCode().toLong()) {
imageView.tag = imageUrl.hashCode().toLong() imageView.tag = imageUrl.hashCode().toLong()
} }
imageView.image = gifImage imageView.updateGifImage(gifImage)
}, },
) )
} }
private fun UIImageView.updateGifImage(image: UIImage?) {
if (this.image != image) {
stopAnimating()
this.image = image
}
if (image != null) {
startAnimating()
}
}
private fun cachedGifImage(imageUrl: String): UIImage? { private fun cachedGifImage(imageUrl: String): UIImage? {
val image = gifImageCache[imageUrl] ?: return null val image = gifImageCache[imageUrl] ?: return null
gifImageCacheOrder.remove(imageUrl) gifImageCacheOrder.remove(imageUrl)

View file

@ -1,14 +1,11 @@
package com.nuvio.app.features.trakt package com.nuvio.app.features.trakt
import platform.Foundation.NSDate import platform.Foundation.NSDate
import platform.Foundation.NSISO8601DateFormatter
import platform.Foundation.timeIntervalSince1970 import platform.Foundation.timeIntervalSince1970
internal actual object TraktPlatformClock { internal actual object TraktPlatformClock {
actual fun nowEpochMs(): Long = (NSDate().timeIntervalSince1970 * 1000.0).toLong() actual fun nowEpochMs(): Long = (NSDate().timeIntervalSince1970 * 1000.0).toLong()
actual fun parseIsoDateTimeToEpochMs(value: String): Long? = actual fun parseIsoDateTimeToEpochMs(value: String): Long? =
NSISO8601DateFormatter() parseTraktIsoDateTimeToEpochMs(value)
.dateFromString(value)
?.let { date -> (date.timeIntervalSince1970 * 1000.0).toLong() }
} }

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=54 CURRENT_PROJECT_VERSION=56
MARKETING_VERSION=0.1.0 MARKETING_VERSION=0.1.17