Merge branch 'cmp-rewrite' into feat/disable-swipe-to-seek

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

View file

@ -13,6 +13,7 @@ import com.nuvio.app.core.auth.AuthStorage
import com.nuvio.app.core.deeplink.handleAppUrl
import com.nuvio.app.core.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)

View file

@ -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",
)

View file

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

View file

@ -5,7 +5,7 @@ import java.time.Instant
internal actual object TraktPlatformClock {
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)
}

View file

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

View file

@ -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<TabsRoute>() == 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,

View file

@ -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()

View file

@ -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()),

View file

@ -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),

View file

@ -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"
private const val ADDON_URL_HEX = "0123456789ABCDEF"

View file

@ -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 {

View file

@ -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(

View file

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

View file

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

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import com.nuvio.app.features.home.PosterShape
import 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,

View file

@ -52,7 +52,8 @@ object CollectionRepository {
runCatching {
val parsed = json.parseToJsonElement(payload)
rawCollectionsJson = parsed
_collections.value = json.decodeFromString<List<Collection>>(payload)
val decoded = json.decodeFromString<List<Collection>>(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<Collection>) {
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<List<Collection>>(jsonString)
_collections.value = imported
_collections.value = CollectionMobileSettingsRepository.applyToCollections(imported)
persist()
imported
}
@ -262,10 +264,15 @@ object CollectionRepository {
internal fun applyFromRemote(collections: List<Collection>, 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()
}

View file

@ -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,
)
}

View file

@ -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
}

View file

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

View file

@ -25,6 +25,7 @@ import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.core.ui.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<LibraryItem?>(null) }
var pendingRemovalTarget by remember { mutableStateOf<LibraryRemovalTarget?>(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<LibrarySection>,
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) },
)
}
}

View file

@ -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 -> {

View file

@ -6,6 +6,7 @@ import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.auth.isAnonymous
import com.nuvio.app.core.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()
}

View file

@ -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 {

View file

@ -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 },
)
}

View file

@ -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,
)

View file

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

View file

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

View file

@ -3,8 +3,13 @@ package com.nuvio.app.features.collection
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.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<List<Collection>>(raw.toString()).single()
val mobileDisabled = collection.copy(
folders = collection.folders.map { folder ->
folder.copy(mobileFocusGifEnabled = false)
},
)
val merged = CollectionJsonPreserver.merge(json, raw, listOf(mobileDisabled))
val mergedFolder = merged
.single()
.jsonObject["folders"]!!
.jsonArray
.single()
.jsonObject
assertTrue(mergedFolder["focusGifEnabled"]!!.jsonPrimitive.boolean)
assertTrue(mergedFolder["mobileFocusGifEnabled"] == null)
}
@Test
fun mobileGifToggleDefaultsIndependentOfTvGifToggle() {
val payload = """
[
{
"id": "collection-1",
"title": "Favorites",
"folders": [
{
"id": "folder-1",
"title": "Movies",
"focusGifUrl": "https://example.com/focus.gif",
"focusGifEnabled": false
}
]
}
]
""".trimIndent()
val folder = json.decodeFromString<List<Collection>>(payload).single().folders.single()
assertFalse(folder.focusGifEnabled)
assertTrue(folder.mobileFocusGifEnabled)
}
}

View file

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

View file

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

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.home.components
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.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
private fun Byte.unsignedInt(): Int = toInt() and 0xFF

View file

@ -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)
}

View file

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