diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
index e899b044..339340ab 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
@@ -13,6 +13,7 @@ import com.nuvio.app.core.auth.AuthStorage
import com.nuvio.app.core.deeplink.handleAppUrl
import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
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.downloads.DownloadsLiveStatusPlatform
import com.nuvio.app.features.downloads.DownloadsPlatformDownloader
@@ -83,6 +84,7 @@ class MainActivity : AppCompatActivity() {
WatchProgressStorage.initialize(applicationContext)
StreamLinkCacheStorage.initialize(applicationContext)
PluginStorage.initialize(applicationContext)
+ CollectionMobileSettingsStorage.initialize(applicationContext)
CollectionStorage.initialize(applicationContext)
DownloadsStorage.initialize(applicationContext)
DownloadsPlatformDownloader.initialize(applicationContext)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt
index 9edf1191..b7243288 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt
@@ -23,6 +23,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_episode_release_notifications",
"nuvio_episode_release_notifications_platform",
"nuvio_watch_progress",
+ "nuvio_collection_mobile_settings",
"nuvio_collections",
"nuvio_plugins",
)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.android.kt
new file mode 100644
index 00000000..caaba36c
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.android.kt
@@ -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()
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt
index 4eda3a91..585cb863 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt
@@ -5,7 +5,7 @@ import java.time.Instant
internal actual object TraktPlatformClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis()
- actual fun parseIsoDateTimeToEpochMs(value: String): Long? = runCatching {
- Instant.parse(value).toEpochMilli()
- }.getOrNull()
+ actual fun parseIsoDateTimeToEpochMs(value: String): Long? =
+ runCatching { Instant.parse(value).toEpochMilli() }.getOrNull()
+ ?: parseTraktIsoDateTimeToEpochMs(value)
}
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 3ed33b85..a6d64118 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -391,6 +391,9 @@
Change to a different profile.
Switch Profile
Open Trakt connection screen
+ No settings found.
+ Search settings...
+ RESULTS
Loading your Trakt lists…
Choose where to save this title on Trakt
Donate
@@ -1118,6 +1121,7 @@
Download failed
Paused %1$s
Remove
+ Remove %1$s from %2$s?
Remove %1$s from your library?
Remove from Library?
Movie
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index 3eebbdac..1d605c3c 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -512,6 +512,7 @@ private fun MainAppContent(
val hapticFeedback = LocalHapticFeedback.current
val coroutineScope = rememberCoroutineScope()
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
+ val currentBackStackEntry by navController.currentBackStackEntryAsState()
val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle()
val liquidGlassNativeTabBarEnabled by remember {
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
@@ -975,6 +976,7 @@ private fun MainAppContent(
val isTabletLayout = maxWidth >= 768.dp
val useNativeBottomTabs =
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
+ val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute() == true
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
profileSwitchLoading = true
selectedTab = AppScreenTab.Home
@@ -1033,6 +1035,7 @@ private fun MainAppContent(
.fillMaxSize()
.padding(innerPadding),
selectedTab = selectedTab,
+ animateHomeCollectionGifs = tabsRouteActive,
onCatalogClick = onCatalogClick,
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
@@ -1952,6 +1955,7 @@ private fun rememberGuardedPopBackStack(
private fun AppTabHost(
selectedTab: AppScreenTab,
modifier: Modifier = Modifier,
+ animateHomeCollectionGifs: Boolean = true,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
@@ -1981,6 +1985,7 @@ private fun AppTabHost(
AppScreenTab.Home -> {
HomeScreen(
modifier = Modifier.fillMaxSize(),
+ animateCollectionGifs = animateHomeCollectionGifs,
onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt
index 603fce83..8892f6e6 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt
@@ -3,6 +3,7 @@ package com.nuvio.app.core.storage
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.features.addons.AddonRepository
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.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository
@@ -44,6 +45,7 @@ internal object LocalAccountDataCleaner {
WatchedRepository.clearLocalState()
ContinueWatchingPreferencesRepository.clearLocalState()
EpisodeReleaseNotificationsRepository.clearLocalState()
+ CollectionMobileSettingsRepository.clearLocalState()
CollectionRepository.clearLocalState()
ThemeSettingsRepository.clearLocalState()
PosterCardStyleRepository.clearLocalState()
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt
index 9dd7a999..58df719e 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt
@@ -4,6 +4,8 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
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.MetaScreenSettingsRepository
import com.nuvio.app.features.mdblist.MdbListMetadataService
@@ -158,6 +160,7 @@ object ProfileSettingsSync {
TmdbSettingsRepository.uiState.map { "tmdb" },
MdbListSettingsRepository.uiState.map { "mdblist" },
MetaScreenSettingsRepository.uiState.map { "meta" },
+ CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" },
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
TraktSettingsRepository.uiState.map { "trakt_settings" },
TraktCommentsSettings.enabled.map { "trakt_comments" },
@@ -202,6 +205,7 @@ object ProfileSettingsSync {
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
+ collectionMobileSettingsPayload = CollectionMobileSettingsStorage.loadPayload().orEmpty().trim(),
continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(),
traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(),
traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(),
@@ -232,6 +236,9 @@ object ProfileSettingsSync {
MetaScreenSettingsStorage.savePayload(blob.features.metaScreenSettingsPayload)
MetaScreenSettingsRepository.onProfileChanged()
+ CollectionMobileSettingsStorage.savePayload(blob.features.collectionMobileSettingsPayload)
+ CollectionMobileSettingsRepository.onProfileChanged()
+
ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload)
ContinueWatchingPreferencesRepository.onProfileChanged()
@@ -251,6 +258,7 @@ object ProfileSettingsSync {
TmdbSettingsRepository.ensureLoaded()
MdbListSettingsRepository.ensureLoaded()
MetaScreenSettingsRepository.ensureLoaded()
+ CollectionMobileSettingsRepository.ensureLoaded()
ContinueWatchingPreferencesRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktCommentsSettings.ensureLoaded()
@@ -272,6 +280,7 @@ object ProfileSettingsSync {
"tmdb=${TmdbSettingsRepository.uiState.value}",
"mdblist=${MdbListSettingsRepository.uiState.value}",
"meta=${MetaScreenSettingsRepository.uiState.value}",
+ "collection_mobile_settings=${CollectionMobileSettingsRepository.uiState.value}",
"continue=${ContinueWatchingPreferencesRepository.uiState.value}",
"trakt_settings=${TraktSettingsRepository.uiState.value}",
"trakt_comments=${TraktCommentsSettings.enabled.value}",
@@ -293,6 +302,7 @@ private data class MobileProfileSettingsFeatures(
@SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
@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("trakt_settings_payload") val traktSettingsPayload: String = "",
@SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()),
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt
index 38c88914..d86a1a81 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt
@@ -44,9 +44,9 @@ private fun buildColorScheme(palette: ThemeColorPalette, amoled: Boolean = false
onSecondary = palette.onSecondaryVariant,
background = if (amoled) Color.Black else palette.background,
onBackground = Color(0xFFF5F7F8),
- surface = if (amoled) Color(0xFF050505) else palette.backgroundElevated,
+ surface = palette.backgroundElevated,
onSurface = Color(0xFFF5F7F8),
- surfaceVariant = if (amoled) Color(0xFF0A0A0A) else palette.backgroundCard,
+ surfaceVariant = palette.backgroundCard,
onSurfaceVariant = Color(0xFF969CA3),
outline = Color(0xFF252A2A),
error = Color(0xFFE36A8A),
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt
index 47b852fe..80f913cb 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt
@@ -12,11 +12,15 @@ internal fun buildAddonResourceUrl(
): String {
val encodedId = id.encodeAddonPathSegment()
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"
} else {
"$baseUrl/$resource/$type/$encodedId/$extraPathSegment.json"
}
+ return resourceUrl + query
}
@@ -43,4 +47,4 @@ internal fun String.encodeAddonPathSegment(): String =
}
}
-private const val ADDON_URL_HEX = "0123456789ABCDEF"
\ No newline at end of file
+private const val ADDON_URL_HEX = "0123456789ABCDEF"
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt
index 0a31a9d7..70b5204f 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt
@@ -195,10 +195,10 @@ object CollectionEditorRepository {
)
}
- fun updateFolderFocusGifEnabled(enabled: Boolean) {
+ fun updateFolderMobileFocusGifEnabled(enabled: Boolean) {
val folder = _uiState.value.editingFolder ?: return
_uiState.value = _uiState.value.copy(
- editingFolder = folder.copy(focusGifEnabled = enabled),
+ editingFolder = folder.copy(mobileFocusGifEnabled = enabled),
)
}
@@ -808,6 +808,8 @@ object CollectionEditorRepository {
folders = state.folders,
)
+ CollectionMobileSettingsRepository.replaceCollectionFolderGifSettings(collection.id, collection.folders)
+
if (state.isNew) {
CollectionRepository.addCollection(collection)
} else {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt
index 1114ac1b..7219395a 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt
@@ -702,8 +702,8 @@ private fun FolderEditorPage(
FolderEditorToggleRow(
title = stringResource(Res.string.collections_editor_show_gif_when_configured),
subtitle = stringResource(Res.string.collections_editor_show_gif_when_configured_desc),
- checked = folder.focusGifEnabled,
- onCheckedChange = { CollectionEditorRepository.updateFolderFocusGifEnabled(it) },
+ checked = folder.mobileFocusGifEnabled,
+ onCheckedChange = { CollectionEditorRepository.updateFolderMobileFocusGifEnabled(it) },
)
FolderEditorToggleRow(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsRepository.kt
new file mode 100644
index 00000000..c122ae63
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsRepository.kt
@@ -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 = emptyMap(),
+)
+
+object CollectionMobileSettingsRepository {
+ private val json = Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+ }
+
+ private val _uiState = MutableStateFlow(CollectionMobileSettingsUiState())
+ val uiState: StateFlow = _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): List {
+ 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) {
+ 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(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 { 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 = emptyList(),
+)
+
+@Serializable
+private data class StoredFolderGifOverride(
+ @SerialName("collection_id") val collectionId: String,
+ @SerialName("folder_id") val folderId: String,
+ val enabled: Boolean = true,
+)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.kt
new file mode 100644
index 00000000..58ac9020
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.kt
@@ -0,0 +1,6 @@
+package com.nuvio.app.features.collection
+
+internal expect object CollectionMobileSettingsStorage {
+ fun loadPayload(): String?
+ fun savePayload(payload: String)
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt
index ba9080d6..31962922 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import com.nuvio.app.features.home.PosterShape
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
enum class FolderViewMode {
TABBED_GRID,
@@ -13,7 +14,7 @@ enum class FolderViewMode {
companion object {
fun fromString(value: String): FolderViewMode =
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(TABBED_GRID.name, ignoreCase = true) -> TABBED_GRID
else -> TABBED_GRID
@@ -168,6 +169,8 @@ data class CollectionFolder(
val coverImageUrl: String? = null,
val focusGifUrl: String? = null,
val focusGifEnabled: Boolean = true,
+ @Transient
+ val mobileFocusGifEnabled: Boolean = true,
val coverEmoji: String? = null,
val tileShape: String = "poster",
val hideTitle: Boolean = false,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt
index 39916184..270e9781 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt
@@ -52,7 +52,8 @@ object CollectionRepository {
runCatching {
val parsed = json.parseToJsonElement(payload)
rawCollectionsJson = parsed
- _collections.value = json.decodeFromString>(payload)
+ val decoded = json.decodeFromString>(payload)
+ _collections.value = CollectionMobileSettingsRepository.applyToCollections(decoded)
}.onFailure { e ->
log.e(e) { "Failed to load collections from storage" }
}
@@ -75,14 +76,15 @@ object CollectionRepository {
fun addCollection(collection: Collection) {
ensureLoaded()
- _collections.value = _collections.value + collection
+ _collections.value = _collections.value + CollectionMobileSettingsRepository.applyToCollection(collection)
persist()
}
fun updateCollection(collection: Collection) {
ensureLoaded()
+ val decorated = CollectionMobileSettingsRepository.applyToCollection(collection)
_collections.value = _collections.value.map {
- if (it.id == collection.id) collection else it
+ if (it.id == collection.id) decorated else it
}
persist()
}
@@ -95,7 +97,7 @@ object CollectionRepository {
fun setCollections(collections: List) {
ensureLoaded()
- _collections.value = collections
+ _collections.value = CollectionMobileSettingsRepository.applyToCollections(collections)
persist()
}
@@ -127,7 +129,7 @@ object CollectionRepository {
return runCatching {
rawCollectionsJson = json.parseToJsonElement(jsonString)
val imported = json.decodeFromString>(jsonString)
- _collections.value = imported
+ _collections.value = CollectionMobileSettingsRepository.applyToCollections(imported)
persist()
imported
}
@@ -262,10 +264,15 @@ object CollectionRepository {
internal fun applyFromRemote(collections: List, rawJson: JsonElement) {
rawCollectionsJson = rawJson
- _collections.value = collections
+ _collections.value = CollectionMobileSettingsRepository.applyToCollections(collections)
persist(sync = false)
}
+ internal fun onMobileSettingsChanged() {
+ if (!hasLoaded) return
+ _collections.value = CollectionMobileSettingsRepository.applyToCollections(_collections.value)
+ }
+
private fun ensureLoaded() {
if (!hasLoaded) initialize()
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt
index 87879839..e549850b 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt
@@ -70,6 +70,7 @@ import org.jetbrains.compose.resources.stringResource
@Composable
fun HomeScreen(
modifier: Modifier = Modifier,
+ animateCollectionGifs: Boolean = true,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
@@ -560,6 +561,7 @@ fun HomeScreen(
collection = collection,
modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding,
+ animateGifs = animateCollectionGifs,
onFolderClick = onFolderClick,
)
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt
index 2c3121aa..dd053375 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt
@@ -37,6 +37,7 @@ fun HomeCollectionRowSection(
collection: Collection,
modifier: Modifier = Modifier,
sectionPadding: Dp? = null,
+ animateGifs: Boolean = true,
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
) {
if (collection.folders.isEmpty()) return
@@ -46,6 +47,7 @@ fun HomeCollectionRowSection(
collection = collection,
modifier = modifier.fillMaxWidth(),
sectionPadding = sectionPadding,
+ animateGifs = animateGifs,
onFolderClick = onFolderClick,
)
} else {
@@ -54,6 +56,7 @@ fun HomeCollectionRowSection(
collection = collection,
modifier = Modifier.fillMaxWidth(),
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
+ animateGifs = animateGifs,
onFolderClick = onFolderClick,
)
}
@@ -65,6 +68,7 @@ private fun HomeCollectionRowSectionContent(
collection: Collection,
modifier: Modifier,
sectionPadding: Dp,
+ animateGifs: Boolean,
onFolderClick: ((collectionId: String, folderId: String) -> Unit)?,
) {
NuvioShelfSection(
@@ -77,6 +81,7 @@ private fun HomeCollectionRowSectionContent(
) { folder ->
CollectionFolderCard(
folder = folder,
+ animateGifs = animateGifs,
onClick = onFolderClick?.let { { it(collection.id, folder.id) } },
)
}
@@ -86,6 +91,7 @@ private fun HomeCollectionRowSectionContent(
private fun CollectionFolderCard(
folder: CollectionFolder,
modifier: Modifier = Modifier,
+ animateGifs: Boolean = true,
onClick: (() -> Unit)? = null,
) {
val posterCardStyle = rememberPosterCardStyleUiState()
@@ -138,7 +144,7 @@ private fun CollectionFolderCard(
contentDescription = folder.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
- animateIfPossible = isAnimatedCollectionFolderImage(folder, imageUrl),
+ animateIfPossible = animateGifs && isAnimatedCollectionFolderImage(folder, imageUrl),
)
}
!folder.coverEmoji.isNullOrBlank() -> {
@@ -180,7 +186,7 @@ private fun CollectionFolderCard(
}
private fun collectionFolderCardImageUrl(folder: CollectionFolder): String? {
- return if (folder.focusGifEnabled) {
+ return if (folder.mobileFocusGifEnabled) {
firstNonBlank(folder.focusGifUrl, folder.coverImageUrl)
} else {
firstNonBlank(folder.coverImageUrl)
@@ -196,5 +202,5 @@ private fun isAnimatedCollectionFolderImage(
imageUrl: String,
): Boolean {
val gifUrl = firstNonBlank(folder.focusGifUrl) ?: return false
- return folder.focusGifEnabled && imageUrl == gifUrl
+ return folder.mobileFocusGifEnabled && imageUrl == gifUrl
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt
index c93d5caa..46c2acdc 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt
@@ -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() {
syncScope.launch {
runCatching {
@@ -417,6 +425,14 @@ internal fun libraryMembershipWithLocal(
putAll(traktMembership)
}
+internal fun libraryMembershipWithRemovedList(
+ currentMembership: Map,
+ listKey: String,
+): Map =
+ currentMembership.toMutableMap().apply {
+ this[listKey] = false
+ }
+
private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem(
id = contentId,
type = contentType,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
index efe6ded9..4a8f78c3 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
@@ -25,6 +25,7 @@ import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.core.ui.NuvioScreenHeader
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.NuvioShelfSection
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 kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
+import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
+private data class LibraryRemovalTarget(
+ val item: LibraryItem,
+ val listKey: String? = null,
+ val listTitle: String? = null,
+)
+
@Composable
fun LibraryScreen(
modifier: Modifier = Modifier,
@@ -46,7 +54,7 @@ fun LibraryScreen(
LibraryRepository.uiState
}.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
- var pendingRemovalItem by remember { mutableStateOf(null) }
+ var pendingRemovalTarget by remember { mutableStateOf(null) }
var observedOfflineState by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
@@ -165,9 +173,15 @@ fun LibraryScreen(
sections = uiState.sections,
onPosterClick = onPosterClick,
onSectionViewAllClick = onSectionViewAllClick,
- onPosterLongClick = { item ->
- if (!isTraktSource) {
- pendingRemovalItem = item
+ onPosterLongClick = { item, section ->
+ pendingRemovalTarget = if (isTraktSource) {
+ LibraryRemovalTarget(
+ item = item,
+ listKey = section.type,
+ listTitle = section.displayTitle,
+ )
+ } else {
+ LibraryRemovalTarget(item = item)
}
},
)
@@ -177,17 +191,38 @@ fun LibraryScreen(
NuvioStatusModal(
title = stringResource(Res.string.library_remove_title),
- message = pendingRemovalItem?.let {
- stringResource(Res.string.library_remove_message, it.name)
+ message = pendingRemovalTarget?.let { target ->
+ 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(),
- isVisible = pendingRemovalItem != null,
+ isVisible = pendingRemovalTarget != null,
confirmText = stringResource(Res.string.library_remove_confirm),
dismissText = stringResource(Res.string.action_cancel),
onConfirm = {
- pendingRemovalItem?.id?.let(LibraryRepository::remove)
- pendingRemovalItem = null
+ val target = pendingRemovalTarget
+ 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,
onPosterClick: ((LibraryItem) -> Unit)?,
onSectionViewAllClick: ((LibrarySection) -> Unit)?,
- onPosterLongClick: (LibraryItem) -> Unit,
+ onPosterLongClick: (LibraryItem, LibrarySection) -> Unit,
) {
items(
items = sections,
@@ -218,7 +253,7 @@ private fun LazyListScope.librarySections(
HomePosterCard(
item = item.toMetaPreview(),
onClick = onPosterClick?.let { { it(item) } },
- onLongClick = { onPosterLongClick(item) },
+ onLongClick = { onPosterLongClick(item, section) },
)
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
index b6320e8a..19d0c0fc 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
@@ -1449,13 +1449,17 @@ fun PlayerScreen(
totalDy += delta.y
if (gestureMode == null) {
+ val holdToSpeedActive = isHoldToSpeedGestureActiveState.value
val horizontalDominant =
playerSettingsUiState.swipeToSeekEnabled &&
- !isHoldToSpeedGestureActiveState.value &&
- abs(totalDx) > viewConfiguration.touchSlop * playerSettingsUiState.swipeToSeekSensitivity.triggerMultiplier &&
+ !holdToSpeedActive &&
+ abs(totalDx) > viewConfiguration.touchSlop *
+ playerSettingsUiState.swipeToSeekSensitivity.triggerMultiplier &&
abs(totalDx) > abs(totalDy)
val verticalDominant =
- abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx)
+ !holdToSpeedActive &&
+ abs(totalDy) > viewConfiguration.touchSlop &&
+ abs(totalDy) > abs(totalDx)
gestureMode = when {
horizontalDominant -> {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
index 0cb6cc27..5760e73e 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
@@ -6,6 +6,7 @@ import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.auth.isAnonymous
import com.nuvio.app.core.network.SupabaseProvider
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.downloads.DownloadsRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository
@@ -156,6 +157,7 @@ object ProfileRepository {
TraktAuthRepository.onProfileChanged()
SearchHistoryRepository.onProfileChanged()
CollectionRepository.onProfileChanged()
+ CollectionMobileSettingsRepository.onProfileChanged()
DownloadsRepository.onProfileChanged()
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt
index c25a67fc..bad6cc11 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt
@@ -33,6 +33,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
+import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -220,7 +221,14 @@ fun SearchScreen(
androidx.compose.foundation.layout.Column(
modifier = Modifier
.fillMaxWidth()
- .background(MaterialTheme.colorScheme.background),
+ .background(MaterialTheme.colorScheme.background)
+ .pointerInput(Unit) {
+ awaitPointerEventScope {
+ while (true) {
+ awaitPointerEvent()
+ }
+ }
+ },
) {
NuvioScreenHeader(
title = headerTitle,
@@ -277,53 +285,61 @@ fun SearchScreen(
onPosterLongClick = onPosterLongClick,
)
} else {
- when {
- uiState.isLoading && uiState.sections.isEmpty() -> {
- items(2) {
- HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
+ val normalizedQuery = query.trim()
+ val isWaitingForSearch = normalizedQuery.isNotBlank() && lastRequestedQuery != normalizedQuery
+ when {
+ isWaitingForSearch -> {
+ items(2) {
+ HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
+ }
}
- }
- uiState.sections.isEmpty() -> {
- item {
- SearchEmptyStateCard(
- reason = uiState.emptyStateReason,
- errorMessage = uiState.errorMessage,
- networkCondition = networkStatusUiState.condition,
- onRetry = {
- val normalizedQuery = query.trim()
- if (normalizedQuery.isNotBlank()) {
- NetworkStatusRepository.requestRefresh(force = true)
- SearchRepository.search(
- query = normalizedQuery,
- addons = addonsUiState.addons,
- )
- }
- },
- )
+ uiState.isLoading && uiState.sections.isEmpty() -> {
+ items(2) {
+ HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
+ }
}
- }
- else -> {
- items(
- items = uiState.sections.withDuplicateSafeLazyKeys { section -> section.key },
- key = { section -> section.lazyKey },
- ) { keyedSection ->
- val section = keyedSection.value
- HomeCatalogRowSection(
- section = section,
- modifier = Modifier.padding(bottom = 12.dp),
- watchedKeys = watchedUiState.watchedKeys,
- onPosterClick = onPosterClick,
- onPosterLongClick = onPosterLongClick,
- )
+ uiState.sections.isEmpty() -> {
+ item {
+ SearchEmptyStateCard(
+ reason = uiState.emptyStateReason,
+ errorMessage = uiState.errorMessage,
+ networkCondition = networkStatusUiState.condition,
+ onRetry = {
+ if (normalizedQuery.isNotBlank()) {
+ NetworkStatusRepository.requestRefresh(force = true)
+ SearchRepository.search(
+ query = normalizedQuery,
+ addons = addonsUiState.addons,
+ )
+ }
+ },
+ modifier = Modifier.padding(horizontal = homeSectionPadding),
+ )
+ }
+ }
+
+ else -> {
+ items(
+ items = uiState.sections.withDuplicateSafeLazyKeys { section -> section.key },
+ key = { section -> section.lazyKey },
+ ) { keyedSection ->
+ val section = keyedSection.value
+ HomeCatalogRowSection(
+ section = section,
+ modifier = Modifier.padding(bottom = 12.dp),
+ watchedKeys = watchedUiState.watchedKeys,
+ onPosterClick = onPosterClick,
+ onPosterLongClick = onPosterLongClick,
+ )
+ }
}
}
}
}
}
}
-}
private fun discoverColumnCountForWidth(screenWidth: Dp): Int =
when {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt
index 4e17b58a..e80c0822 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt
@@ -7,9 +7,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.Text
import androidx.compose.runtime.Composable
@@ -20,24 +17,17 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.auth.AuthRepository
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.NuvioStatusModal
import com.nuvio.app.core.ui.NuvioSurfaceCard
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
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.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_not_signed_in
import nuvio.composeapp.generated.resources.settings_account_sign_out
@@ -62,7 +52,6 @@ private fun AccountSettingsBody(
) {
val authState by AuthRepository.state.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
- var showDeleteConfirm by remember { mutableStateOf(false) }
var showSignOutConfirm by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -131,35 +120,6 @@ private fun AccountSettingsBody(
text = stringResource(Res.string.settings_account_sign_out),
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(
@@ -174,17 +134,4 @@ private fun AccountSettingsBody(
},
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 },
- )
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
index 7cee50f7..b7e24ed7 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -29,10 +30,19 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
+import androidx.compose.ui.geometry.Offset
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.unit.dp
import androidx.compose.ui.unit.max
@@ -69,8 +79,14 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepositor
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.compose_settings_page_root
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
+private val SettingsSearchRevealThreshold = 28.dp
+private const val SettingsSearchRevealAnimationMillis = 240L
+private const val SettingsSearchRevealHapticDelayMillis = 90L
+
@Composable
fun SettingsScreen(
modifier: Modifier = Modifier,
@@ -337,7 +353,66 @@ private fun MobileSettingsScreen(
) {
val saveableStateHolder = rememberSaveableStateHolder()
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 {
val previousPage = page.previousPage()
NuvioScreenHeader(
@@ -347,20 +422,33 @@ private fun MobileSettingsScreen(
}
when (page) {
- SettingsPage.Root -> settingsRootContent(
- isTablet = false,
- onPlaybackClick = { onPageChange(SettingsPage.Playback) },
- onAppearanceClick = { onPageChange(SettingsPage.Appearance) },
- onNotificationsClick = { onPageChange(SettingsPage.Notifications) },
- onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) },
- onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
- onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
- onSupportersContributorsClick = onSupportersContributorsClick,
- onCheckForUpdatesClick = onCheckForUpdatesClick,
- onDownloadsClick = onDownloadsClick,
- onAccountClick = onAccountClick,
- onSwitchProfileClick = onSwitchProfile,
- )
+ 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,
+ onPlaybackClick = { onPageChange(SettingsPage.Playback) },
+ onAppearanceClick = { onPageChange(SettingsPage.Appearance) },
+ onNotificationsClick = { onPageChange(SettingsPage.Notifications) },
+ onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) },
+ onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
+ onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
+ onSupportersContributorsClick = onSupportersContributorsClick,
+ onCheckForUpdatesClick = onCheckForUpdatesClick,
+ onDownloadsClick = onDownloadsClick,
+ onAccountClick = onAccountClick,
+ onSwitchProfileClick = onSwitchProfile,
+ )
+ }
+ }
SettingsPage.Account -> accountSettingsContent(
isTablet = false,
)
@@ -464,6 +552,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
private fun TabletSettingsScreen(
page: SettingsPage,
@@ -572,11 +702,54 @@ private fun TabletSettingsScreen(
}
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 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(
state = listState,
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier
+ .fillMaxSize()
+ .nestedScroll(rootSearchRevealConnection),
contentPadding = PaddingValues(
start = 40.dp,
top = topOffset,
@@ -589,7 +762,11 @@ private fun TabletSettingsScreen(
val previousPage = page.previousPage()
TabletPageHeader(
title = if (page == SettingsPage.Root) {
- stringResource(activeCategory.labelRes)
+ if (settingsSearchQuery.isBlank()) {
+ stringResource(activeCategory.labelRes)
+ } else {
+ stringResource(Res.string.compose_settings_page_root)
+ }
} else {
stringResource(page.titleRes)
},
@@ -598,23 +775,36 @@ private fun TabletSettingsScreen(
)
}
when (page) {
- SettingsPage.Root -> settingsRootContent(
- isTablet = true,
- onPlaybackClick = { openInlinePage(SettingsPage.Playback) },
- onAppearanceClick = { openInlinePage(SettingsPage.Appearance) },
- onNotificationsClick = { openInlinePage(SettingsPage.Notifications) },
- onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) },
- onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
- onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
- onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
- onCheckForUpdatesClick = onCheckForUpdatesClick,
- onDownloadsClick = onDownloadsClick,
- onAccountClick = { openInlinePage(SettingsPage.Account) },
- onSwitchProfileClick = onSwitchProfile,
- showAccountSection = activeCategory == SettingsCategory.Account,
- showGeneralSection = activeCategory == SettingsCategory.General,
- showAboutSection = activeCategory == SettingsCategory.About,
- )
+ 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,
+ onPlaybackClick = { openInlinePage(SettingsPage.Playback) },
+ onAppearanceClick = { openInlinePage(SettingsPage.Appearance) },
+ onNotificationsClick = { openInlinePage(SettingsPage.Notifications) },
+ onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) },
+ onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
+ onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
+ onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
+ onCheckForUpdatesClick = onCheckForUpdatesClick,
+ onDownloadsClick = onDownloadsClick,
+ onAccountClick = { openInlinePage(SettingsPage.Account) },
+ onSwitchProfileClick = onSwitchProfile,
+ showAccountSection = activeCategory == SettingsCategory.Account,
+ showGeneralSection = activeCategory == SettingsCategory.General,
+ showAboutSection = activeCategory == SettingsCategory.About,
+ )
+ }
+ }
SettingsPage.Account -> accountSettingsContent(
isTablet = true,
)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
new file mode 100644
index 00000000..bdacf0de
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
@@ -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 {
+ 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()
+
+ 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,
+) {
+ 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,
+) {
+ 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,
+ 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,
+): List {
+ 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 }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt
new file mode 100644
index 00000000..79b5bd07
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt
@@ -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
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt
index 66f227dd..5f83cd99 100644
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt
@@ -3,8 +3,13 @@ package com.nuvio.app.features.collection
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
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.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@@ -178,4 +183,69 @@ class CollectionSourceSerializationTest {
assertTrue(merged.contains(""""customField":"keep-me""""))
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>(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>(payload).single().folders.single()
+
+ assertFalse(folder.focusGifEnabled)
+ assertTrue(folder.mobileFocusGifEnabled)
+ }
}
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt
index 553140ee..b3e60b1d 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt
@@ -46,6 +46,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"trakt_auth_payload",
"trakt_library_payload",
"trakt_settings_payload",
+ "collection_mobile_settings_payload",
"collections_payload",
)
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.ios.kt
new file mode 100644
index 00000000..e214807d
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.ios.kt
@@ -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))
+ }
+}
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt
index 11d9fe42..7f1e5c69 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt
@@ -2,6 +2,7 @@ package com.nuvio.app.features.home.components
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -51,6 +52,16 @@ private data class ExpandedGifFrames(
val tickCentiseconds: Int,
)
+private class GifImageViewHolder {
+ var imageView: UIImageView? = null
+
+ fun clear() {
+ imageView?.stopAnimating()
+ imageView?.image = null
+ imageView = null
+ }
+}
+
@OptIn(ExperimentalForeignApi::class)
@Composable
internal actual fun CollectionCardRemoteImage(
@@ -76,6 +87,13 @@ internal actual fun CollectionCardRemoteImage(
gifImage = loadGifImage(imageUrl)
}
+ val imageViewHolder = remember(imageUrl) { GifImageViewHolder() }
+ DisposableEffect(imageUrl) {
+ onDispose {
+ imageViewHolder.clear()
+ }
+ }
+
UIKitView(
modifier = modifier,
factory = {
@@ -83,19 +101,31 @@ internal actual fun CollectionCardRemoteImage(
contentMode = UIViewContentMode.UIViewContentModeScaleAspectFill
clipsToBounds = true
userInteractionEnabled = false
- image = gifImage
tag = imageUrl.hashCode().toLong()
+ imageViewHolder.imageView = this
+ updateGifImage(gifImage)
}
},
update = { imageView ->
+ imageViewHolder.imageView = imageView
if (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? {
val image = gifImageCache[imageUrl] ?: return null
gifImageCacheOrder.remove(imageUrl)
@@ -311,4 +341,4 @@ private fun ByteArray.readUnsignedShort(startIndex: Int): Int {
return this[startIndex].unsignedInt() or (this[startIndex + 1].unsignedInt() shl 8)
}
-private fun Byte.unsignedInt(): Int = toInt() and 0xFF
\ No newline at end of file
+private fun Byte.unsignedInt(): Int = toInt() and 0xFF
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt
index 77e6d585..0094c594 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt
@@ -1,14 +1,11 @@
package com.nuvio.app.features.trakt
import platform.Foundation.NSDate
-import platform.Foundation.NSISO8601DateFormatter
import platform.Foundation.timeIntervalSince1970
internal actual object TraktPlatformClock {
actual fun nowEpochMs(): Long = (NSDate().timeIntervalSince1970 * 1000.0).toLong()
actual fun parseIsoDateTimeToEpochMs(value: String): Long? =
- NSISO8601DateFormatter()
- .dateFromString(value)
- ?.let { date -> (date.timeIntervalSince1970 * 1000.0).toLong() }
+ parseTraktIsoDateTimeToEpochMs(value)
}
diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig
index 965f9e75..837f01a4 100644
--- a/iosApp/Configuration/Version.xcconfig
+++ b/iosApp/Configuration/Version.xcconfig
@@ -1,3 +1,3 @@
-CURRENT_PROJECT_VERSION=54
-MARKETING_VERSION=0.1.0
+CURRENT_PROJECT_VERSION=56
+MARKETING_VERSION=0.1.17