mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 15:01:59 +00:00
Merge branch 'cmp-rewrite' into feat/disable-swipe-to-seek
This commit is contained in:
commit
d959596615
33 changed files with 1759 additions and 170 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.nuvio.app.features.collection
|
||||
|
||||
internal expect object CollectionMobileSettingsStorage {
|
||||
fun loadPayload(): String?
|
||||
fun savePayload(payload: String)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ internal actual object PlatformLocalAccountDataCleaner {
|
|||
"trakt_auth_payload",
|
||||
"trakt_library_payload",
|
||||
"trakt_settings_payload",
|
||||
"collection_mobile_settings_payload",
|
||||
"collections_payload",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
CURRENT_PROJECT_VERSION=54
|
||||
MARKETING_VERSION=0.1.0
|
||||
CURRENT_PROJECT_VERSION=56
|
||||
MARKETING_VERSION=0.1.17
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue