mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 15:32:01 +00:00
Merge branch 'cmp-rewrite' into feat/disable-swipe-gestures
This commit is contained in:
commit
e93ffa02c5
33 changed files with 1757 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.deeplink.handleAppUrl
|
||||||
import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
|
import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
|
||||||
import com.nuvio.app.features.addons.AddonStorage
|
import com.nuvio.app.features.addons.AddonStorage
|
||||||
|
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
|
||||||
import com.nuvio.app.features.collection.CollectionStorage
|
import com.nuvio.app.features.collection.CollectionStorage
|
||||||
import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform
|
import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform
|
||||||
import com.nuvio.app.features.downloads.DownloadsPlatformDownloader
|
import com.nuvio.app.features.downloads.DownloadsPlatformDownloader
|
||||||
|
|
@ -83,6 +84,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
WatchProgressStorage.initialize(applicationContext)
|
WatchProgressStorage.initialize(applicationContext)
|
||||||
StreamLinkCacheStorage.initialize(applicationContext)
|
StreamLinkCacheStorage.initialize(applicationContext)
|
||||||
PluginStorage.initialize(applicationContext)
|
PluginStorage.initialize(applicationContext)
|
||||||
|
CollectionMobileSettingsStorage.initialize(applicationContext)
|
||||||
CollectionStorage.initialize(applicationContext)
|
CollectionStorage.initialize(applicationContext)
|
||||||
DownloadsStorage.initialize(applicationContext)
|
DownloadsStorage.initialize(applicationContext)
|
||||||
DownloadsPlatformDownloader.initialize(applicationContext)
|
DownloadsPlatformDownloader.initialize(applicationContext)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ internal actual object PlatformLocalAccountDataCleaner {
|
||||||
"nuvio_episode_release_notifications",
|
"nuvio_episode_release_notifications",
|
||||||
"nuvio_episode_release_notifications_platform",
|
"nuvio_episode_release_notifications_platform",
|
||||||
"nuvio_watch_progress",
|
"nuvio_watch_progress",
|
||||||
|
"nuvio_collection_mobile_settings",
|
||||||
"nuvio_collections",
|
"nuvio_collections",
|
||||||
"nuvio_plugins",
|
"nuvio_plugins",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
internal actual object TraktPlatformClock {
|
||||||
actual fun nowEpochMs(): Long = System.currentTimeMillis()
|
actual fun nowEpochMs(): Long = System.currentTimeMillis()
|
||||||
|
|
||||||
actual fun parseIsoDateTimeToEpochMs(value: String): Long? = runCatching {
|
actual fun parseIsoDateTimeToEpochMs(value: String): Long? =
|
||||||
Instant.parse(value).toEpochMilli()
|
runCatching { Instant.parse(value).toEpochMilli() }.getOrNull()
|
||||||
}.getOrNull()
|
?: parseTraktIsoDateTimeToEpochMs(value)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -391,6 +391,9 @@
|
||||||
<string name="compose_settings_root_switch_profile_description">Change to a different profile.</string>
|
<string name="compose_settings_root_switch_profile_description">Change to a different profile.</string>
|
||||||
<string name="compose_settings_root_switch_profile_title">Switch Profile</string>
|
<string name="compose_settings_root_switch_profile_title">Switch Profile</string>
|
||||||
<string name="compose_settings_root_trakt_description">Open Trakt connection screen</string>
|
<string name="compose_settings_root_trakt_description">Open Trakt connection screen</string>
|
||||||
|
<string name="settings_search_empty">No settings found.</string>
|
||||||
|
<string name="settings_search_placeholder">Search settings...</string>
|
||||||
|
<string name="settings_search_results_section">RESULTS</string>
|
||||||
<string name="compose_trakt_list_picker_loading">Loading your Trakt lists…</string>
|
<string name="compose_trakt_list_picker_loading">Loading your Trakt lists…</string>
|
||||||
<string name="compose_trakt_list_picker_subtitle">Choose where to save this title on Trakt</string>
|
<string name="compose_trakt_list_picker_subtitle">Choose where to save this title on Trakt</string>
|
||||||
<string name="action_donate">Donate</string>
|
<string name="action_donate">Donate</string>
|
||||||
|
|
@ -1113,6 +1116,7 @@
|
||||||
<string name="downloads_live_failed">Download failed</string>
|
<string name="downloads_live_failed">Download failed</string>
|
||||||
<string name="downloads_live_paused">Paused %1$s</string>
|
<string name="downloads_live_paused">Paused %1$s</string>
|
||||||
<string name="library_remove_confirm">Remove</string>
|
<string name="library_remove_confirm">Remove</string>
|
||||||
|
<string name="library_remove_from_list_message">Remove %1$s from %2$s?</string>
|
||||||
<string name="library_remove_message">Remove %1$s from your library?</string>
|
<string name="library_remove_message">Remove %1$s from your library?</string>
|
||||||
<string name="library_remove_title">Remove from Library?</string>
|
<string name="library_remove_title">Remove from Library?</string>
|
||||||
<string name="media_movie">Movie</string>
|
<string name="media_movie">Movie</string>
|
||||||
|
|
|
||||||
|
|
@ -512,6 +512,7 @@ private fun MainAppContent(
|
||||||
val hapticFeedback = LocalHapticFeedback.current
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
|
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
|
||||||
|
val currentBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle()
|
val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle()
|
||||||
val liquidGlassNativeTabBarEnabled by remember {
|
val liquidGlassNativeTabBarEnabled by remember {
|
||||||
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
|
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
|
||||||
|
|
@ -975,6 +976,7 @@ private fun MainAppContent(
|
||||||
val isTabletLayout = maxWidth >= 768.dp
|
val isTabletLayout = maxWidth >= 768.dp
|
||||||
val useNativeBottomTabs =
|
val useNativeBottomTabs =
|
||||||
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
|
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
|
||||||
|
val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute<TabsRoute>() == true
|
||||||
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
|
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
|
||||||
profileSwitchLoading = true
|
profileSwitchLoading = true
|
||||||
selectedTab = AppScreenTab.Home
|
selectedTab = AppScreenTab.Home
|
||||||
|
|
@ -1033,6 +1035,7 @@ private fun MainAppContent(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding),
|
.padding(innerPadding),
|
||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
|
animateHomeCollectionGifs = tabsRouteActive,
|
||||||
onCatalogClick = onCatalogClick,
|
onCatalogClick = onCatalogClick,
|
||||||
onPosterClick = { meta ->
|
onPosterClick = { meta ->
|
||||||
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
|
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
|
||||||
|
|
@ -1952,6 +1955,7 @@ private fun rememberGuardedPopBackStack(
|
||||||
private fun AppTabHost(
|
private fun AppTabHost(
|
||||||
selectedTab: AppScreenTab,
|
selectedTab: AppScreenTab,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
animateHomeCollectionGifs: Boolean = true,
|
||||||
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
|
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
|
||||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||||
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
||||||
|
|
@ -1981,6 +1985,7 @@ private fun AppTabHost(
|
||||||
AppScreenTab.Home -> {
|
AppScreenTab.Home -> {
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
animateCollectionGifs = animateHomeCollectionGifs,
|
||||||
onCatalogClick = onCatalogClick,
|
onCatalogClick = onCatalogClick,
|
||||||
onPosterClick = onPosterClick,
|
onPosterClick = onPosterClick,
|
||||||
onPosterLongClick = onPosterLongClick,
|
onPosterLongClick = onPosterLongClick,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.core.storage
|
||||||
import com.nuvio.app.core.build.AppFeaturePolicy
|
import com.nuvio.app.core.build.AppFeaturePolicy
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.catalog.CatalogRepository
|
import com.nuvio.app.features.catalog.CatalogRepository
|
||||||
|
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
|
||||||
import com.nuvio.app.features.collection.CollectionRepository
|
import com.nuvio.app.features.collection.CollectionRepository
|
||||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||||
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
||||||
|
|
@ -44,6 +45,7 @@ internal object LocalAccountDataCleaner {
|
||||||
WatchedRepository.clearLocalState()
|
WatchedRepository.clearLocalState()
|
||||||
ContinueWatchingPreferencesRepository.clearLocalState()
|
ContinueWatchingPreferencesRepository.clearLocalState()
|
||||||
EpisodeReleaseNotificationsRepository.clearLocalState()
|
EpisodeReleaseNotificationsRepository.clearLocalState()
|
||||||
|
CollectionMobileSettingsRepository.clearLocalState()
|
||||||
CollectionRepository.clearLocalState()
|
CollectionRepository.clearLocalState()
|
||||||
ThemeSettingsRepository.clearLocalState()
|
ThemeSettingsRepository.clearLocalState()
|
||||||
PosterCardStyleRepository.clearLocalState()
|
PosterCardStyleRepository.clearLocalState()
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import co.touchlab.kermit.Logger
|
||||||
import com.nuvio.app.core.auth.AuthRepository
|
import com.nuvio.app.core.auth.AuthRepository
|
||||||
import com.nuvio.app.core.auth.AuthState
|
import com.nuvio.app.core.auth.AuthState
|
||||||
import com.nuvio.app.core.network.SupabaseProvider
|
import com.nuvio.app.core.network.SupabaseProvider
|
||||||
|
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
|
||||||
|
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
|
||||||
import com.nuvio.app.features.details.MetaScreenSettingsStorage
|
import com.nuvio.app.features.details.MetaScreenSettingsStorage
|
||||||
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
||||||
import com.nuvio.app.features.mdblist.MdbListMetadataService
|
import com.nuvio.app.features.mdblist.MdbListMetadataService
|
||||||
|
|
@ -158,6 +160,7 @@ object ProfileSettingsSync {
|
||||||
TmdbSettingsRepository.uiState.map { "tmdb" },
|
TmdbSettingsRepository.uiState.map { "tmdb" },
|
||||||
MdbListSettingsRepository.uiState.map { "mdblist" },
|
MdbListSettingsRepository.uiState.map { "mdblist" },
|
||||||
MetaScreenSettingsRepository.uiState.map { "meta" },
|
MetaScreenSettingsRepository.uiState.map { "meta" },
|
||||||
|
CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" },
|
||||||
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
|
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
|
||||||
TraktSettingsRepository.uiState.map { "trakt_settings" },
|
TraktSettingsRepository.uiState.map { "trakt_settings" },
|
||||||
TraktCommentsSettings.enabled.map { "trakt_comments" },
|
TraktCommentsSettings.enabled.map { "trakt_comments" },
|
||||||
|
|
@ -202,6 +205,7 @@ object ProfileSettingsSync {
|
||||||
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
|
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
|
||||||
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
|
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
|
||||||
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
|
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
|
||||||
|
collectionMobileSettingsPayload = CollectionMobileSettingsStorage.loadPayload().orEmpty().trim(),
|
||||||
continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(),
|
continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(),
|
||||||
traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(),
|
traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(),
|
||||||
traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(),
|
traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(),
|
||||||
|
|
@ -232,6 +236,9 @@ object ProfileSettingsSync {
|
||||||
MetaScreenSettingsStorage.savePayload(blob.features.metaScreenSettingsPayload)
|
MetaScreenSettingsStorage.savePayload(blob.features.metaScreenSettingsPayload)
|
||||||
MetaScreenSettingsRepository.onProfileChanged()
|
MetaScreenSettingsRepository.onProfileChanged()
|
||||||
|
|
||||||
|
CollectionMobileSettingsStorage.savePayload(blob.features.collectionMobileSettingsPayload)
|
||||||
|
CollectionMobileSettingsRepository.onProfileChanged()
|
||||||
|
|
||||||
ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload)
|
ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload)
|
||||||
ContinueWatchingPreferencesRepository.onProfileChanged()
|
ContinueWatchingPreferencesRepository.onProfileChanged()
|
||||||
|
|
||||||
|
|
@ -251,6 +258,7 @@ object ProfileSettingsSync {
|
||||||
TmdbSettingsRepository.ensureLoaded()
|
TmdbSettingsRepository.ensureLoaded()
|
||||||
MdbListSettingsRepository.ensureLoaded()
|
MdbListSettingsRepository.ensureLoaded()
|
||||||
MetaScreenSettingsRepository.ensureLoaded()
|
MetaScreenSettingsRepository.ensureLoaded()
|
||||||
|
CollectionMobileSettingsRepository.ensureLoaded()
|
||||||
ContinueWatchingPreferencesRepository.ensureLoaded()
|
ContinueWatchingPreferencesRepository.ensureLoaded()
|
||||||
TraktSettingsRepository.ensureLoaded()
|
TraktSettingsRepository.ensureLoaded()
|
||||||
TraktCommentsSettings.ensureLoaded()
|
TraktCommentsSettings.ensureLoaded()
|
||||||
|
|
@ -272,6 +280,7 @@ object ProfileSettingsSync {
|
||||||
"tmdb=${TmdbSettingsRepository.uiState.value}",
|
"tmdb=${TmdbSettingsRepository.uiState.value}",
|
||||||
"mdblist=${MdbListSettingsRepository.uiState.value}",
|
"mdblist=${MdbListSettingsRepository.uiState.value}",
|
||||||
"meta=${MetaScreenSettingsRepository.uiState.value}",
|
"meta=${MetaScreenSettingsRepository.uiState.value}",
|
||||||
|
"collection_mobile_settings=${CollectionMobileSettingsRepository.uiState.value}",
|
||||||
"continue=${ContinueWatchingPreferencesRepository.uiState.value}",
|
"continue=${ContinueWatchingPreferencesRepository.uiState.value}",
|
||||||
"trakt_settings=${TraktSettingsRepository.uiState.value}",
|
"trakt_settings=${TraktSettingsRepository.uiState.value}",
|
||||||
"trakt_comments=${TraktCommentsSettings.enabled.value}",
|
"trakt_comments=${TraktCommentsSettings.enabled.value}",
|
||||||
|
|
@ -293,6 +302,7 @@ private data class MobileProfileSettingsFeatures(
|
||||||
@SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()),
|
@SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()),
|
||||||
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
|
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
|
||||||
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",
|
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",
|
||||||
|
@SerialName("collection_mobile_settings_payload") val collectionMobileSettingsPayload: String = "",
|
||||||
@SerialName("continue_watching_settings_payload") val continueWatchingSettingsPayload: String = "",
|
@SerialName("continue_watching_settings_payload") val continueWatchingSettingsPayload: String = "",
|
||||||
@SerialName("trakt_settings_payload") val traktSettingsPayload: String = "",
|
@SerialName("trakt_settings_payload") val traktSettingsPayload: String = "",
|
||||||
@SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()),
|
@SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()),
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,9 @@ private fun buildColorScheme(palette: ThemeColorPalette, amoled: Boolean = false
|
||||||
onSecondary = palette.onSecondaryVariant,
|
onSecondary = palette.onSecondaryVariant,
|
||||||
background = if (amoled) Color.Black else palette.background,
|
background = if (amoled) Color.Black else palette.background,
|
||||||
onBackground = Color(0xFFF5F7F8),
|
onBackground = Color(0xFFF5F7F8),
|
||||||
surface = if (amoled) Color(0xFF050505) else palette.backgroundElevated,
|
surface = palette.backgroundElevated,
|
||||||
onSurface = Color(0xFFF5F7F8),
|
onSurface = Color(0xFFF5F7F8),
|
||||||
surfaceVariant = if (amoled) Color(0xFF0A0A0A) else palette.backgroundCard,
|
surfaceVariant = palette.backgroundCard,
|
||||||
onSurfaceVariant = Color(0xFF969CA3),
|
onSurfaceVariant = Color(0xFF969CA3),
|
||||||
outline = Color(0xFF252A2A),
|
outline = Color(0xFF252A2A),
|
||||||
error = Color(0xFFE36A8A),
|
error = Color(0xFFE36A8A),
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,15 @@ internal fun buildAddonResourceUrl(
|
||||||
): String {
|
): String {
|
||||||
val encodedId = id.encodeAddonPathSegment()
|
val encodedId = id.encodeAddonPathSegment()
|
||||||
val baseUrl = addonTransportBaseUrl(manifestUrl)
|
val baseUrl = addonTransportBaseUrl(manifestUrl)
|
||||||
return if (extraPathSegment.isNullOrEmpty()) {
|
val query = manifestUrl.substringAfter("?", "").let { query ->
|
||||||
|
if (query.isBlank()) "" else "?$query"
|
||||||
|
}
|
||||||
|
val resourceUrl = if (extraPathSegment.isNullOrEmpty()) {
|
||||||
"$baseUrl/$resource/$type/$encodedId.json"
|
"$baseUrl/$resource/$type/$encodedId.json"
|
||||||
} else {
|
} else {
|
||||||
"$baseUrl/$resource/$type/$encodedId/$extraPathSegment.json"
|
"$baseUrl/$resource/$type/$encodedId/$extraPathSegment.json"
|
||||||
}
|
}
|
||||||
|
return resourceUrl + query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -195,10 +195,10 @@ object CollectionEditorRepository {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateFolderFocusGifEnabled(enabled: Boolean) {
|
fun updateFolderMobileFocusGifEnabled(enabled: Boolean) {
|
||||||
val folder = _uiState.value.editingFolder ?: return
|
val folder = _uiState.value.editingFolder ?: return
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
editingFolder = folder.copy(focusGifEnabled = enabled),
|
editingFolder = folder.copy(mobileFocusGifEnabled = enabled),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -808,6 +808,8 @@ object CollectionEditorRepository {
|
||||||
folders = state.folders,
|
folders = state.folders,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
CollectionMobileSettingsRepository.replaceCollectionFolderGifSettings(collection.id, collection.folders)
|
||||||
|
|
||||||
if (state.isNew) {
|
if (state.isNew) {
|
||||||
CollectionRepository.addCollection(collection)
|
CollectionRepository.addCollection(collection)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -702,8 +702,8 @@ private fun FolderEditorPage(
|
||||||
FolderEditorToggleRow(
|
FolderEditorToggleRow(
|
||||||
title = stringResource(Res.string.collections_editor_show_gif_when_configured),
|
title = stringResource(Res.string.collections_editor_show_gif_when_configured),
|
||||||
subtitle = stringResource(Res.string.collections_editor_show_gif_when_configured_desc),
|
subtitle = stringResource(Res.string.collections_editor_show_gif_when_configured_desc),
|
||||||
checked = folder.focusGifEnabled,
|
checked = folder.mobileFocusGifEnabled,
|
||||||
onCheckedChange = { CollectionEditorRepository.updateFolderFocusGifEnabled(it) },
|
onCheckedChange = { CollectionEditorRepository.updateFolderMobileFocusGifEnabled(it) },
|
||||||
)
|
)
|
||||||
|
|
||||||
FolderEditorToggleRow(
|
FolderEditorToggleRow(
|
||||||
|
|
|
||||||
|
|
@ -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 com.nuvio.app.features.home.PosterShape
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.Transient
|
||||||
|
|
||||||
enum class FolderViewMode {
|
enum class FolderViewMode {
|
||||||
TABBED_GRID,
|
TABBED_GRID,
|
||||||
|
|
@ -13,7 +14,7 @@ enum class FolderViewMode {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromString(value: String): FolderViewMode =
|
fun fromString(value: String): FolderViewMode =
|
||||||
when {
|
when {
|
||||||
value.equals(FOLLOW_LAYOUT.name, ignoreCase = true) -> ROWS
|
value.equals(FOLLOW_LAYOUT.name, ignoreCase = true) -> FOLLOW_LAYOUT
|
||||||
value.equals(ROWS.name, ignoreCase = true) -> ROWS
|
value.equals(ROWS.name, ignoreCase = true) -> ROWS
|
||||||
value.equals(TABBED_GRID.name, ignoreCase = true) -> TABBED_GRID
|
value.equals(TABBED_GRID.name, ignoreCase = true) -> TABBED_GRID
|
||||||
else -> TABBED_GRID
|
else -> TABBED_GRID
|
||||||
|
|
@ -168,6 +169,8 @@ data class CollectionFolder(
|
||||||
val coverImageUrl: String? = null,
|
val coverImageUrl: String? = null,
|
||||||
val focusGifUrl: String? = null,
|
val focusGifUrl: String? = null,
|
||||||
val focusGifEnabled: Boolean = true,
|
val focusGifEnabled: Boolean = true,
|
||||||
|
@Transient
|
||||||
|
val mobileFocusGifEnabled: Boolean = true,
|
||||||
val coverEmoji: String? = null,
|
val coverEmoji: String? = null,
|
||||||
val tileShape: String = "poster",
|
val tileShape: String = "poster",
|
||||||
val hideTitle: Boolean = false,
|
val hideTitle: Boolean = false,
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,8 @@ object CollectionRepository {
|
||||||
runCatching {
|
runCatching {
|
||||||
val parsed = json.parseToJsonElement(payload)
|
val parsed = json.parseToJsonElement(payload)
|
||||||
rawCollectionsJson = parsed
|
rawCollectionsJson = parsed
|
||||||
_collections.value = json.decodeFromString<List<Collection>>(payload)
|
val decoded = json.decodeFromString<List<Collection>>(payload)
|
||||||
|
_collections.value = CollectionMobileSettingsRepository.applyToCollections(decoded)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
log.e(e) { "Failed to load collections from storage" }
|
log.e(e) { "Failed to load collections from storage" }
|
||||||
}
|
}
|
||||||
|
|
@ -75,14 +76,15 @@ object CollectionRepository {
|
||||||
|
|
||||||
fun addCollection(collection: Collection) {
|
fun addCollection(collection: Collection) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
_collections.value = _collections.value + collection
|
_collections.value = _collections.value + CollectionMobileSettingsRepository.applyToCollection(collection)
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateCollection(collection: Collection) {
|
fun updateCollection(collection: Collection) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
|
val decorated = CollectionMobileSettingsRepository.applyToCollection(collection)
|
||||||
_collections.value = _collections.value.map {
|
_collections.value = _collections.value.map {
|
||||||
if (it.id == collection.id) collection else it
|
if (it.id == collection.id) decorated else it
|
||||||
}
|
}
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
@ -95,7 +97,7 @@ object CollectionRepository {
|
||||||
|
|
||||||
fun setCollections(collections: List<Collection>) {
|
fun setCollections(collections: List<Collection>) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
_collections.value = collections
|
_collections.value = CollectionMobileSettingsRepository.applyToCollections(collections)
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,7 +129,7 @@ object CollectionRepository {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
rawCollectionsJson = json.parseToJsonElement(jsonString)
|
rawCollectionsJson = json.parseToJsonElement(jsonString)
|
||||||
val imported = json.decodeFromString<List<Collection>>(jsonString)
|
val imported = json.decodeFromString<List<Collection>>(jsonString)
|
||||||
_collections.value = imported
|
_collections.value = CollectionMobileSettingsRepository.applyToCollections(imported)
|
||||||
persist()
|
persist()
|
||||||
imported
|
imported
|
||||||
}
|
}
|
||||||
|
|
@ -262,10 +264,15 @@ object CollectionRepository {
|
||||||
|
|
||||||
internal fun applyFromRemote(collections: List<Collection>, rawJson: JsonElement) {
|
internal fun applyFromRemote(collections: List<Collection>, rawJson: JsonElement) {
|
||||||
rawCollectionsJson = rawJson
|
rawCollectionsJson = rawJson
|
||||||
_collections.value = collections
|
_collections.value = CollectionMobileSettingsRepository.applyToCollections(collections)
|
||||||
persist(sync = false)
|
persist(sync = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun onMobileSettingsChanged() {
|
||||||
|
if (!hasLoaded) return
|
||||||
|
_collections.value = CollectionMobileSettingsRepository.applyToCollections(_collections.value)
|
||||||
|
}
|
||||||
|
|
||||||
private fun ensureLoaded() {
|
private fun ensureLoaded() {
|
||||||
if (!hasLoaded) initialize()
|
if (!hasLoaded) initialize()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ import org.jetbrains.compose.resources.stringResource
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
animateCollectionGifs: Boolean = true,
|
||||||
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
|
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
|
||||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||||
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
||||||
|
|
@ -560,6 +561,7 @@ fun HomeScreen(
|
||||||
collection = collection,
|
collection = collection,
|
||||||
modifier = Modifier.padding(bottom = 12.dp),
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
sectionPadding = homeSectionPadding,
|
sectionPadding = homeSectionPadding,
|
||||||
|
animateGifs = animateCollectionGifs,
|
||||||
onFolderClick = onFolderClick,
|
onFolderClick = onFolderClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ fun HomeCollectionRowSection(
|
||||||
collection: Collection,
|
collection: Collection,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
sectionPadding: Dp? = null,
|
sectionPadding: Dp? = null,
|
||||||
|
animateGifs: Boolean = true,
|
||||||
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
|
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
if (collection.folders.isEmpty()) return
|
if (collection.folders.isEmpty()) return
|
||||||
|
|
@ -46,6 +47,7 @@ fun HomeCollectionRowSection(
|
||||||
collection = collection,
|
collection = collection,
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
sectionPadding = sectionPadding,
|
sectionPadding = sectionPadding,
|
||||||
|
animateGifs = animateGifs,
|
||||||
onFolderClick = onFolderClick,
|
onFolderClick = onFolderClick,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -54,6 +56,7 @@ fun HomeCollectionRowSection(
|
||||||
collection = collection,
|
collection = collection,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
|
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
|
||||||
|
animateGifs = animateGifs,
|
||||||
onFolderClick = onFolderClick,
|
onFolderClick = onFolderClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -65,6 +68,7 @@ private fun HomeCollectionRowSectionContent(
|
||||||
collection: Collection,
|
collection: Collection,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
sectionPadding: Dp,
|
sectionPadding: Dp,
|
||||||
|
animateGifs: Boolean,
|
||||||
onFolderClick: ((collectionId: String, folderId: String) -> Unit)?,
|
onFolderClick: ((collectionId: String, folderId: String) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
NuvioShelfSection(
|
NuvioShelfSection(
|
||||||
|
|
@ -77,6 +81,7 @@ private fun HomeCollectionRowSectionContent(
|
||||||
) { folder ->
|
) { folder ->
|
||||||
CollectionFolderCard(
|
CollectionFolderCard(
|
||||||
folder = folder,
|
folder = folder,
|
||||||
|
animateGifs = animateGifs,
|
||||||
onClick = onFolderClick?.let { { it(collection.id, folder.id) } },
|
onClick = onFolderClick?.let { { it(collection.id, folder.id) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -86,6 +91,7 @@ private fun HomeCollectionRowSectionContent(
|
||||||
private fun CollectionFolderCard(
|
private fun CollectionFolderCard(
|
||||||
folder: CollectionFolder,
|
folder: CollectionFolder,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
animateGifs: Boolean = true,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val posterCardStyle = rememberPosterCardStyleUiState()
|
val posterCardStyle = rememberPosterCardStyleUiState()
|
||||||
|
|
@ -138,7 +144,7 @@ private fun CollectionFolderCard(
|
||||||
contentDescription = folder.title,
|
contentDescription = folder.title,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
animateIfPossible = isAnimatedCollectionFolderImage(folder, imageUrl),
|
animateIfPossible = animateGifs && isAnimatedCollectionFolderImage(folder, imageUrl),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
!folder.coverEmoji.isNullOrBlank() -> {
|
!folder.coverEmoji.isNullOrBlank() -> {
|
||||||
|
|
@ -180,7 +186,7 @@ private fun CollectionFolderCard(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun collectionFolderCardImageUrl(folder: CollectionFolder): String? {
|
private fun collectionFolderCardImageUrl(folder: CollectionFolder): String? {
|
||||||
return if (folder.focusGifEnabled) {
|
return if (folder.mobileFocusGifEnabled) {
|
||||||
firstNonBlank(folder.focusGifUrl, folder.coverImageUrl)
|
firstNonBlank(folder.focusGifUrl, folder.coverImageUrl)
|
||||||
} else {
|
} else {
|
||||||
firstNonBlank(folder.coverImageUrl)
|
firstNonBlank(folder.coverImageUrl)
|
||||||
|
|
@ -196,5 +202,5 @@ private fun isAnimatedCollectionFolderImage(
|
||||||
imageUrl: String,
|
imageUrl: String,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val gifUrl = firstNonBlank(folder.focusGifUrl) ?: return false
|
val gifUrl = firstNonBlank(folder.focusGifUrl) ?: return false
|
||||||
return folder.focusGifEnabled && imageUrl == gifUrl
|
return folder.mobileFocusGifEnabled && imageUrl == gifUrl
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,14 @@ object LibraryRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun removeFromList(item: LibraryItem, listKey: String) {
|
||||||
|
val desiredMembership = libraryMembershipWithRemovedList(
|
||||||
|
currentMembership = getMembershipSnapshot(item),
|
||||||
|
listKey = listKey,
|
||||||
|
)
|
||||||
|
applyMembershipChanges(item, desiredMembership)
|
||||||
|
}
|
||||||
|
|
||||||
private fun pushToServer() {
|
private fun pushToServer() {
|
||||||
syncScope.launch {
|
syncScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
|
@ -417,6 +425,14 @@ internal fun libraryMembershipWithLocal(
|
||||||
putAll(traktMembership)
|
putAll(traktMembership)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun libraryMembershipWithRemovedList(
|
||||||
|
currentMembership: Map<String, Boolean>,
|
||||||
|
listKey: String,
|
||||||
|
): Map<String, Boolean> =
|
||||||
|
currentMembership.toMutableMap().apply {
|
||||||
|
this[listKey] = false
|
||||||
|
}
|
||||||
|
|
||||||
private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem(
|
private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem(
|
||||||
id = contentId,
|
id = contentId,
|
||||||
type = contentType,
|
type = contentType,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import com.nuvio.app.core.ui.NuvioScreen
|
||||||
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
||||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||||
import com.nuvio.app.core.ui.NuvioStatusModal
|
import com.nuvio.app.core.ui.NuvioStatusModal
|
||||||
|
import com.nuvio.app.core.ui.NuvioToastController
|
||||||
import com.nuvio.app.core.ui.NuvioViewAllPillSize
|
import com.nuvio.app.core.ui.NuvioViewAllPillSize
|
||||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||||
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
||||||
|
|
@ -33,8 +34,15 @@ import com.nuvio.app.features.home.components.HomeSkeletonRow
|
||||||
import com.nuvio.app.features.profiles.ProfileRepository
|
import com.nuvio.app.features.profiles.ProfileRepository
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import nuvio.composeapp.generated.resources.*
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
|
private data class LibraryRemovalTarget(
|
||||||
|
val item: LibraryItem,
|
||||||
|
val listKey: String? = null,
|
||||||
|
val listTitle: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryScreen(
|
fun LibraryScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -46,7 +54,7 @@ fun LibraryScreen(
|
||||||
LibraryRepository.uiState
|
LibraryRepository.uiState
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
||||||
var pendingRemovalItem by remember { mutableStateOf<LibraryItem?>(null) }
|
var pendingRemovalTarget by remember { mutableStateOf<LibraryRemovalTarget?>(null) }
|
||||||
var observedOfflineState by remember { mutableStateOf(false) }
|
var observedOfflineState by remember { mutableStateOf(false) }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
|
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
|
||||||
|
|
@ -165,9 +173,15 @@ fun LibraryScreen(
|
||||||
sections = uiState.sections,
|
sections = uiState.sections,
|
||||||
onPosterClick = onPosterClick,
|
onPosterClick = onPosterClick,
|
||||||
onSectionViewAllClick = onSectionViewAllClick,
|
onSectionViewAllClick = onSectionViewAllClick,
|
||||||
onPosterLongClick = { item ->
|
onPosterLongClick = { item, section ->
|
||||||
if (!isTraktSource) {
|
pendingRemovalTarget = if (isTraktSource) {
|
||||||
pendingRemovalItem = item
|
LibraryRemovalTarget(
|
||||||
|
item = item,
|
||||||
|
listKey = section.type,
|
||||||
|
listTitle = section.displayTitle,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LibraryRemovalTarget(item = item)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -177,17 +191,38 @@ fun LibraryScreen(
|
||||||
|
|
||||||
NuvioStatusModal(
|
NuvioStatusModal(
|
||||||
title = stringResource(Res.string.library_remove_title),
|
title = stringResource(Res.string.library_remove_title),
|
||||||
message = pendingRemovalItem?.let {
|
message = pendingRemovalTarget?.let { target ->
|
||||||
stringResource(Res.string.library_remove_message, it.name)
|
val listTitle = target.listTitle
|
||||||
|
if (listTitle.isNullOrBlank()) {
|
||||||
|
stringResource(Res.string.library_remove_message, target.item.name)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.library_remove_from_list_message, target.item.name, listTitle)
|
||||||
|
}
|
||||||
}.orEmpty(),
|
}.orEmpty(),
|
||||||
isVisible = pendingRemovalItem != null,
|
isVisible = pendingRemovalTarget != null,
|
||||||
confirmText = stringResource(Res.string.library_remove_confirm),
|
confirmText = stringResource(Res.string.library_remove_confirm),
|
||||||
dismissText = stringResource(Res.string.action_cancel),
|
dismissText = stringResource(Res.string.action_cancel),
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
pendingRemovalItem?.id?.let(LibraryRepository::remove)
|
val target = pendingRemovalTarget
|
||||||
pendingRemovalItem = null
|
pendingRemovalTarget = null
|
||||||
|
target?.let {
|
||||||
|
val listKey = target.listKey
|
||||||
|
if (listKey.isNullOrBlank()) {
|
||||||
|
LibraryRepository.remove(target.item.id)
|
||||||
|
} else {
|
||||||
|
coroutineScope.launch {
|
||||||
|
runCatching {
|
||||||
|
LibraryRepository.removeFromList(target.item, listKey)
|
||||||
|
}.onFailure { error ->
|
||||||
|
NuvioToastController.show(
|
||||||
|
error.message ?: getString(Res.string.trakt_lists_update_failed),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDismiss = { pendingRemovalItem = null },
|
onDismiss = { pendingRemovalTarget = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,7 +230,7 @@ private fun LazyListScope.librarySections(
|
||||||
sections: List<LibrarySection>,
|
sections: List<LibrarySection>,
|
||||||
onPosterClick: ((LibraryItem) -> Unit)?,
|
onPosterClick: ((LibraryItem) -> Unit)?,
|
||||||
onSectionViewAllClick: ((LibrarySection) -> Unit)?,
|
onSectionViewAllClick: ((LibrarySection) -> Unit)?,
|
||||||
onPosterLongClick: (LibraryItem) -> Unit,
|
onPosterLongClick: (LibraryItem, LibrarySection) -> Unit,
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
items = sections,
|
items = sections,
|
||||||
|
|
@ -218,7 +253,7 @@ private fun LazyListScope.librarySections(
|
||||||
HomePosterCard(
|
HomePosterCard(
|
||||||
item = item.toMetaPreview(),
|
item = item.toMetaPreview(),
|
||||||
onClick = onPosterClick?.let { { it(item) } },
|
onClick = onPosterClick?.let { { it(item) } },
|
||||||
onLongClick = { onPosterLongClick(item) },
|
onLongClick = { onPosterLongClick(item, section) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1449,14 +1449,16 @@ fun PlayerScreen(
|
||||||
totalDy += delta.y
|
totalDy += delta.y
|
||||||
|
|
||||||
if (gestureMode == null) {
|
if (gestureMode == null) {
|
||||||
|
val holdToSpeedActive = isHoldToSpeedGestureActiveState.value
|
||||||
val horizontalDominant =
|
val horizontalDominant =
|
||||||
!isHoldToSpeedGestureActiveState.value &&
|
!holdToSpeedActive &&
|
||||||
abs(totalDx) > viewConfiguration.touchSlop &&
|
abs(totalDx) > viewConfiguration.touchSlop &&
|
||||||
abs(totalDx) > abs(totalDy)
|
abs(totalDx) > abs(totalDy)
|
||||||
val verticalDominant =
|
val verticalDominant =
|
||||||
playerSettingsUiState.swipeGesturesEnabled &&
|
playerSettingsUiState.swipeGesturesEnabled &&
|
||||||
abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx)
|
!holdToSpeedActive &&
|
||||||
|
abs(totalDy) > viewConfiguration.touchSlop &&
|
||||||
|
abs(totalDy) > abs(totalDx)
|
||||||
gestureMode = when {
|
gestureMode = when {
|
||||||
horizontalDominant -> {
|
horizontalDominant -> {
|
||||||
deactivateHoldToSpeedState.value()
|
deactivateHoldToSpeedState.value()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import com.nuvio.app.core.auth.AuthState
|
||||||
import com.nuvio.app.core.auth.isAnonymous
|
import com.nuvio.app.core.auth.isAnonymous
|
||||||
import com.nuvio.app.core.network.SupabaseProvider
|
import com.nuvio.app.core.network.SupabaseProvider
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
|
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
|
||||||
import com.nuvio.app.features.collection.CollectionRepository
|
import com.nuvio.app.features.collection.CollectionRepository
|
||||||
import com.nuvio.app.features.downloads.DownloadsRepository
|
import com.nuvio.app.features.downloads.DownloadsRepository
|
||||||
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
||||||
|
|
@ -156,6 +157,7 @@ object ProfileRepository {
|
||||||
TraktAuthRepository.onProfileChanged()
|
TraktAuthRepository.onProfileChanged()
|
||||||
SearchHistoryRepository.onProfileChanged()
|
SearchHistoryRepository.onProfileChanged()
|
||||||
CollectionRepository.onProfileChanged()
|
CollectionRepository.onProfileChanged()
|
||||||
|
CollectionMobileSettingsRepository.onProfileChanged()
|
||||||
DownloadsRepository.onProfileChanged()
|
DownloadsRepository.onProfileChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
|
@ -220,7 +221,14 @@ fun SearchScreen(
|
||||||
androidx.compose.foundation.layout.Column(
|
androidx.compose.foundation.layout.Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colorScheme.background),
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
awaitPointerEvent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
NuvioScreenHeader(
|
NuvioScreenHeader(
|
||||||
title = headerTitle,
|
title = headerTitle,
|
||||||
|
|
@ -277,7 +285,15 @@ fun SearchScreen(
|
||||||
onPosterLongClick = onPosterLongClick,
|
onPosterLongClick = onPosterLongClick,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
val normalizedQuery = query.trim()
|
||||||
|
val isWaitingForSearch = normalizedQuery.isNotBlank() && lastRequestedQuery != normalizedQuery
|
||||||
when {
|
when {
|
||||||
|
isWaitingForSearch -> {
|
||||||
|
items(2) {
|
||||||
|
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
uiState.isLoading && uiState.sections.isEmpty() -> {
|
uiState.isLoading && uiState.sections.isEmpty() -> {
|
||||||
items(2) {
|
items(2) {
|
||||||
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
|
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
|
||||||
|
|
@ -291,7 +307,6 @@ fun SearchScreen(
|
||||||
errorMessage = uiState.errorMessage,
|
errorMessage = uiState.errorMessage,
|
||||||
networkCondition = networkStatusUiState.condition,
|
networkCondition = networkStatusUiState.condition,
|
||||||
onRetry = {
|
onRetry = {
|
||||||
val normalizedQuery = query.trim()
|
|
||||||
if (normalizedQuery.isNotBlank()) {
|
if (normalizedQuery.isNotBlank()) {
|
||||||
NetworkStatusRepository.requestRefresh(force = true)
|
NetworkStatusRepository.requestRefresh(force = true)
|
||||||
SearchRepository.search(
|
SearchRepository.search(
|
||||||
|
|
@ -300,6 +315,7 @@ fun SearchScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
modifier = Modifier.padding(horizontal = homeSectionPadding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -322,7 +338,7 @@ fun SearchScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun discoverColumnCountForWidth(screenWidth: Dp): Int =
|
private fun discoverColumnCountForWidth(screenWidth: Dp): Int =
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,6 @@ import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -20,24 +17,17 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.nuvio.app.core.auth.AuthRepository
|
import com.nuvio.app.core.auth.AuthRepository
|
||||||
import com.nuvio.app.core.auth.AuthState
|
import com.nuvio.app.core.auth.AuthState
|
||||||
import com.nuvio.app.core.auth.isAnonymous
|
|
||||||
import com.nuvio.app.core.ui.NuvioPrimaryButton
|
import com.nuvio.app.core.ui.NuvioPrimaryButton
|
||||||
import com.nuvio.app.core.ui.NuvioStatusModal
|
import com.nuvio.app.core.ui.NuvioStatusModal
|
||||||
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import nuvio.composeapp.generated.resources.Res
|
import nuvio.composeapp.generated.resources.Res
|
||||||
import nuvio.composeapp.generated.resources.action_cancel
|
import nuvio.composeapp.generated.resources.action_cancel
|
||||||
import nuvio.composeapp.generated.resources.action_delete
|
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_account
|
import nuvio.composeapp.generated.resources.compose_settings_page_account
|
||||||
import nuvio.composeapp.generated.resources.settings_account_delete_account
|
|
||||||
import nuvio.composeapp.generated.resources.settings_account_delete_account_description
|
|
||||||
import nuvio.composeapp.generated.resources.settings_account_delete_confirm_message
|
|
||||||
import nuvio.composeapp.generated.resources.settings_account_delete_confirm_title
|
|
||||||
import nuvio.composeapp.generated.resources.settings_account_email
|
import nuvio.composeapp.generated.resources.settings_account_email
|
||||||
import nuvio.composeapp.generated.resources.settings_account_not_signed_in
|
import nuvio.composeapp.generated.resources.settings_account_not_signed_in
|
||||||
import nuvio.composeapp.generated.resources.settings_account_sign_out
|
import nuvio.composeapp.generated.resources.settings_account_sign_out
|
||||||
|
|
@ -62,7 +52,6 @@ private fun AccountSettingsBody(
|
||||||
) {
|
) {
|
||||||
val authState by AuthRepository.state.collectAsStateWithLifecycle()
|
val authState by AuthRepository.state.collectAsStateWithLifecycle()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
|
||||||
var showSignOutConfirm by remember { mutableStateOf(false) }
|
var showSignOutConfirm by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
|
@ -131,35 +120,6 @@ private fun AccountSettingsBody(
|
||||||
text = stringResource(Res.string.settings_account_sign_out),
|
text = stringResource(Res.string.settings_account_sign_out),
|
||||||
onClick = { showSignOutConfirm = true },
|
onClick = { showSignOutConfirm = true },
|
||||||
)
|
)
|
||||||
|
|
||||||
if (authState is AuthState.Authenticated && !(authState as AuthState.Authenticated).isAnonymous) {
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = { showDeleteConfirm = true },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(52.dp),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.error.copy(alpha = 0.12f),
|
|
||||||
contentColor = MaterialTheme.colorScheme.error,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(Res.string.settings_account_delete_account),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = stringResource(Res.string.settings_account_delete_account_description),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
NuvioStatusModal(
|
NuvioStatusModal(
|
||||||
|
|
@ -174,17 +134,4 @@ private fun AccountSettingsBody(
|
||||||
},
|
},
|
||||||
onDismiss = { showSignOutConfirm = false },
|
onDismiss = { showSignOutConfirm = false },
|
||||||
)
|
)
|
||||||
|
|
||||||
NuvioStatusModal(
|
|
||||||
title = stringResource(Res.string.settings_account_delete_confirm_title),
|
|
||||||
message = stringResource(Res.string.settings_account_delete_confirm_message),
|
|
||||||
isVisible = showDeleteConfirm,
|
|
||||||
confirmText = stringResource(Res.string.action_delete),
|
|
||||||
dismissText = stringResource(Res.string.action_cancel),
|
|
||||||
onConfirm = {
|
|
||||||
showDeleteConfirm = false
|
|
||||||
scope.launch { AuthRepository.deleteAccount() }
|
|
||||||
},
|
|
||||||
onDismiss = { showDeleteConfirm = false },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
|
@ -29,10 +30,19 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.max
|
import androidx.compose.ui.unit.max
|
||||||
|
|
@ -66,8 +76,14 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepositor
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState
|
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState
|
||||||
import nuvio.composeapp.generated.resources.Res
|
import nuvio.composeapp.generated.resources.Res
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_root
|
import nuvio.composeapp.generated.resources.compose_settings_page_root
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
|
private val SettingsSearchRevealThreshold = 28.dp
|
||||||
|
private const val SettingsSearchRevealAnimationMillis = 240L
|
||||||
|
private const val SettingsSearchRevealHapticDelayMillis = 90L
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -331,7 +347,66 @@ private fun MobileSettingsScreen(
|
||||||
) {
|
) {
|
||||||
val saveableStateHolder = rememberSaveableStateHolder()
|
val saveableStateHolder = rememberSaveableStateHolder()
|
||||||
saveableStateHolder.SaveableStateProvider(page.name) {
|
saveableStateHolder.SaveableStateProvider(page.name) {
|
||||||
NuvioScreen {
|
var settingsSearchQuery by rememberSaveable { mutableStateOf("") }
|
||||||
|
var rootSearchVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var rootSearchRevealAnimating by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
|
val hapticScope = rememberCoroutineScope()
|
||||||
|
val rootSearchRevealConnection = rememberSettingsRootSearchRevealConnection(
|
||||||
|
page = page,
|
||||||
|
listState = listState,
|
||||||
|
query = settingsSearchQuery,
|
||||||
|
searchVisible = rootSearchVisible,
|
||||||
|
) {
|
||||||
|
rootSearchVisible = true
|
||||||
|
rootSearchRevealAnimating = true
|
||||||
|
hapticScope.launch {
|
||||||
|
delay(SettingsSearchRevealHapticDelayMillis)
|
||||||
|
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val searchEntries = settingsSearchEntries(
|
||||||
|
pluginsEnabled = AppFeaturePolicy.pluginsEnabled,
|
||||||
|
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
|
||||||
|
switchProfileAvailable = onSwitchProfile != null,
|
||||||
|
checkForUpdatesAvailable = onCheckForUpdatesClick != null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun openSearchTarget(target: SettingsSearchTarget) {
|
||||||
|
when (target) {
|
||||||
|
is SettingsSearchTarget.Page -> when (target.page) {
|
||||||
|
SettingsPage.Account -> onAccountClick()
|
||||||
|
SettingsPage.SupportersContributors -> onSupportersContributorsClick()
|
||||||
|
SettingsPage.ContinueWatching -> onContinueWatchingClick()
|
||||||
|
SettingsPage.Addons -> onAddonsClick()
|
||||||
|
SettingsPage.Plugins -> {
|
||||||
|
if (AppFeaturePolicy.pluginsEnabled) {
|
||||||
|
onPluginsClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsPage.Homescreen -> onHomescreenClick()
|
||||||
|
SettingsPage.MetaScreen -> onMetaScreenClick()
|
||||||
|
else -> onPageChange(target.page)
|
||||||
|
}
|
||||||
|
SettingsSearchTarget.Downloads -> onDownloadsClick()
|
||||||
|
SettingsSearchTarget.Collections -> onCollectionsClick()
|
||||||
|
SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke()
|
||||||
|
SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(rootSearchRevealAnimating) {
|
||||||
|
if (rootSearchRevealAnimating) {
|
||||||
|
delay(SettingsSearchRevealAnimationMillis)
|
||||||
|
rootSearchRevealAnimating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NuvioScreen(
|
||||||
|
modifier = Modifier.nestedScroll(rootSearchRevealConnection),
|
||||||
|
listState = listState,
|
||||||
|
) {
|
||||||
stickyHeader {
|
stickyHeader {
|
||||||
val previousPage = page.previousPage()
|
val previousPage = page.previousPage()
|
||||||
NuvioScreenHeader(
|
NuvioScreenHeader(
|
||||||
|
|
@ -341,7 +416,18 @@ private fun MobileSettingsScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
when (page) {
|
when (page) {
|
||||||
SettingsPage.Root -> settingsRootContent(
|
SettingsPage.Root -> {
|
||||||
|
settingsSearchRootContent(
|
||||||
|
query = settingsSearchQuery,
|
||||||
|
entries = searchEntries,
|
||||||
|
isTablet = false,
|
||||||
|
showSearchField = rootSearchVisible,
|
||||||
|
animateSearchField = rootSearchRevealAnimating,
|
||||||
|
onQueryChange = { settingsSearchQuery = it },
|
||||||
|
onTargetClick = { openSearchTarget(it) },
|
||||||
|
)
|
||||||
|
if (settingsSearchQuery.isBlank()) {
|
||||||
|
settingsRootContent(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
onPlaybackClick = { onPageChange(SettingsPage.Playback) },
|
onPlaybackClick = { onPageChange(SettingsPage.Playback) },
|
||||||
onAppearanceClick = { onPageChange(SettingsPage.Appearance) },
|
onAppearanceClick = { onPageChange(SettingsPage.Appearance) },
|
||||||
|
|
@ -355,6 +441,8 @@ private fun MobileSettingsScreen(
|
||||||
onAccountClick = onAccountClick,
|
onAccountClick = onAccountClick,
|
||||||
onSwitchProfileClick = onSwitchProfile,
|
onSwitchProfileClick = onSwitchProfile,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
SettingsPage.Account -> accountSettingsContent(
|
SettingsPage.Account -> accountSettingsContent(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
)
|
)
|
||||||
|
|
@ -457,6 +545,48 @@ private fun MobileSettingsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberSettingsRootSearchRevealConnection(
|
||||||
|
page: SettingsPage,
|
||||||
|
listState: LazyListState,
|
||||||
|
query: String,
|
||||||
|
searchVisible: Boolean,
|
||||||
|
onReveal: () -> Unit,
|
||||||
|
): NestedScrollConnection {
|
||||||
|
val revealThresholdPx = with(LocalDensity.current) { SettingsSearchRevealThreshold.toPx() }
|
||||||
|
val currentOnReveal by rememberUpdatedState(onReveal)
|
||||||
|
var pullDistancePx by remember(page) { mutableStateOf(0f) }
|
||||||
|
var revealTriggered by remember(page) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
return remember(page, listState, query, searchVisible, revealThresholdPx) {
|
||||||
|
object : NestedScrollConnection {
|
||||||
|
override fun onPostScroll(
|
||||||
|
consumed: Offset,
|
||||||
|
available: Offset,
|
||||||
|
source: NestedScrollSource,
|
||||||
|
): Offset {
|
||||||
|
val isRootAtTop = page == SettingsPage.Root &&
|
||||||
|
listState.firstVisibleItemIndex == 0 &&
|
||||||
|
listState.firstVisibleItemScrollOffset == 0
|
||||||
|
val canRevealSearch = isRootAtTop && !searchVisible && !revealTriggered && query.isBlank()
|
||||||
|
|
||||||
|
if (canRevealSearch && available.y > 0f) {
|
||||||
|
pullDistancePx += available.y
|
||||||
|
if (pullDistancePx >= revealThresholdPx) {
|
||||||
|
pullDistancePx = 0f
|
||||||
|
revealTriggered = true
|
||||||
|
currentOnReveal()
|
||||||
|
}
|
||||||
|
} else if (!isRootAtTop || available.y < 0f) {
|
||||||
|
pullDistancePx = 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
return Offset.Zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TabletSettingsScreen(
|
private fun TabletSettingsScreen(
|
||||||
page: SettingsPage,
|
page: SettingsPage,
|
||||||
|
|
@ -564,11 +694,54 @@ private fun TabletSettingsScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
saveableStateHolder.SaveableStateProvider(page.name) {
|
saveableStateHolder.SaveableStateProvider(page.name) {
|
||||||
|
var settingsSearchQuery by rememberSaveable { mutableStateOf("") }
|
||||||
|
var rootSearchVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var rootSearchRevealAnimating by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
|
val hapticScope = rememberCoroutineScope()
|
||||||
|
val searchEntries = settingsSearchEntries(
|
||||||
|
pluginsEnabled = AppFeaturePolicy.pluginsEnabled,
|
||||||
|
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
|
||||||
|
switchProfileAvailable = onSwitchProfile != null,
|
||||||
|
checkForUpdatesAvailable = onCheckForUpdatesClick != null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun openSearchTarget(target: SettingsSearchTarget) {
|
||||||
|
when (target) {
|
||||||
|
is SettingsSearchTarget.Page -> openInlinePage(target.page)
|
||||||
|
SettingsSearchTarget.Downloads -> onDownloadsClick()
|
||||||
|
SettingsSearchTarget.Collections -> onCollectionsClick()
|
||||||
|
SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke()
|
||||||
|
SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val bottomOverlayPadding = LocalNuvioBottomNavigationOverlayPadding.current
|
val bottomOverlayPadding = LocalNuvioBottomNavigationOverlayPadding.current
|
||||||
|
val rootSearchRevealConnection = rememberSettingsRootSearchRevealConnection(
|
||||||
|
page = page,
|
||||||
|
listState = listState,
|
||||||
|
query = settingsSearchQuery,
|
||||||
|
searchVisible = rootSearchVisible,
|
||||||
|
) {
|
||||||
|
rootSearchVisible = true
|
||||||
|
rootSearchRevealAnimating = true
|
||||||
|
hapticScope.launch {
|
||||||
|
delay(SettingsSearchRevealHapticDelayMillis)
|
||||||
|
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(rootSearchRevealAnimating) {
|
||||||
|
if (rootSearchRevealAnimating) {
|
||||||
|
delay(SettingsSearchRevealAnimationMillis)
|
||||||
|
rootSearchRevealAnimating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.nestedScroll(rootSearchRevealConnection),
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
start = 40.dp,
|
start = 40.dp,
|
||||||
top = topOffset,
|
top = topOffset,
|
||||||
|
|
@ -581,7 +754,11 @@ private fun TabletSettingsScreen(
|
||||||
val previousPage = page.previousPage()
|
val previousPage = page.previousPage()
|
||||||
TabletPageHeader(
|
TabletPageHeader(
|
||||||
title = if (page == SettingsPage.Root) {
|
title = if (page == SettingsPage.Root) {
|
||||||
|
if (settingsSearchQuery.isBlank()) {
|
||||||
stringResource(activeCategory.labelRes)
|
stringResource(activeCategory.labelRes)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.compose_settings_page_root)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
stringResource(page.titleRes)
|
stringResource(page.titleRes)
|
||||||
},
|
},
|
||||||
|
|
@ -590,7 +767,18 @@ private fun TabletSettingsScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
when (page) {
|
when (page) {
|
||||||
SettingsPage.Root -> settingsRootContent(
|
SettingsPage.Root -> {
|
||||||
|
settingsSearchRootContent(
|
||||||
|
query = settingsSearchQuery,
|
||||||
|
entries = searchEntries,
|
||||||
|
isTablet = true,
|
||||||
|
showSearchField = rootSearchVisible,
|
||||||
|
animateSearchField = rootSearchRevealAnimating,
|
||||||
|
onQueryChange = { settingsSearchQuery = it },
|
||||||
|
onTargetClick = { openSearchTarget(it) },
|
||||||
|
)
|
||||||
|
if (settingsSearchQuery.isBlank()) {
|
||||||
|
settingsRootContent(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
onPlaybackClick = { openInlinePage(SettingsPage.Playback) },
|
onPlaybackClick = { openInlinePage(SettingsPage.Playback) },
|
||||||
onAppearanceClick = { openInlinePage(SettingsPage.Appearance) },
|
onAppearanceClick = { openInlinePage(SettingsPage.Appearance) },
|
||||||
|
|
@ -607,6 +795,8 @@ private fun TabletSettingsScreen(
|
||||||
showGeneralSection = activeCategory == SettingsCategory.General,
|
showGeneralSection = activeCategory == SettingsCategory.General,
|
||||||
showAboutSection = activeCategory == SettingsCategory.About,
|
showAboutSection = activeCategory == SettingsCategory.About,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
SettingsPage.Account -> accountSettingsContent(
|
SettingsPage.Account -> accountSettingsContent(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.boolean
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertNotNull
|
import kotlin.test.assertNotNull
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
|
@ -178,4 +183,69 @@ class CollectionSourceSerializationTest {
|
||||||
assertTrue(merged.contains(""""customField":"keep-me""""))
|
assertTrue(merged.contains(""""customField":"keep-me""""))
|
||||||
assertTrue(merged.contains(""""traktListId":123456"""))
|
assertTrue(merged.contains(""""traktListId":123456"""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mobileGifToggleDoesNotEnterCollectionJsonOrOverwriteTvGifToggle() {
|
||||||
|
val raw = json.parseToJsonElement(
|
||||||
|
"""
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "collection-1",
|
||||||
|
"title": "Favorites",
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"id": "folder-1",
|
||||||
|
"title": "Movies",
|
||||||
|
"coverImageUrl": "https://example.com/poster.jpg",
|
||||||
|
"focusGifUrl": "https://example.com/focus.gif",
|
||||||
|
"focusGifEnabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
val collection = json.decodeFromString<List<Collection>>(raw.toString()).single()
|
||||||
|
val mobileDisabled = collection.copy(
|
||||||
|
folders = collection.folders.map { folder ->
|
||||||
|
folder.copy(mobileFocusGifEnabled = false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val merged = CollectionJsonPreserver.merge(json, raw, listOf(mobileDisabled))
|
||||||
|
val mergedFolder = merged
|
||||||
|
.single()
|
||||||
|
.jsonObject["folders"]!!
|
||||||
|
.jsonArray
|
||||||
|
.single()
|
||||||
|
.jsonObject
|
||||||
|
|
||||||
|
assertTrue(mergedFolder["focusGifEnabled"]!!.jsonPrimitive.boolean)
|
||||||
|
assertTrue(mergedFolder["mobileFocusGifEnabled"] == null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mobileGifToggleDefaultsIndependentOfTvGifToggle() {
|
||||||
|
val payload = """
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "collection-1",
|
||||||
|
"title": "Favorites",
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"id": "folder-1",
|
||||||
|
"title": "Movies",
|
||||||
|
"focusGifUrl": "https://example.com/focus.gif",
|
||||||
|
"focusGifEnabled": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val folder = json.decodeFromString<List<Collection>>(payload).single().folders.single()
|
||||||
|
|
||||||
|
assertFalse(folder.focusGifEnabled)
|
||||||
|
assertTrue(folder.mobileFocusGifEnabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ internal actual object PlatformLocalAccountDataCleaner {
|
||||||
"trakt_auth_payload",
|
"trakt_auth_payload",
|
||||||
"trakt_library_payload",
|
"trakt_library_payload",
|
||||||
"trakt_settings_payload",
|
"trakt_settings_payload",
|
||||||
|
"collection_mobile_settings_payload",
|
||||||
"collections_payload",
|
"collections_payload",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.LaunchedEffect
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -51,6 +52,16 @@ private data class ExpandedGifFrames(
|
||||||
val tickCentiseconds: Int,
|
val tickCentiseconds: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private class GifImageViewHolder {
|
||||||
|
var imageView: UIImageView? = null
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
imageView?.stopAnimating()
|
||||||
|
imageView?.image = null
|
||||||
|
imageView = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal actual fun CollectionCardRemoteImage(
|
internal actual fun CollectionCardRemoteImage(
|
||||||
|
|
@ -76,6 +87,13 @@ internal actual fun CollectionCardRemoteImage(
|
||||||
gifImage = loadGifImage(imageUrl)
|
gifImage = loadGifImage(imageUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val imageViewHolder = remember(imageUrl) { GifImageViewHolder() }
|
||||||
|
DisposableEffect(imageUrl) {
|
||||||
|
onDispose {
|
||||||
|
imageViewHolder.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
UIKitView(
|
UIKitView(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
factory = {
|
factory = {
|
||||||
|
|
@ -83,19 +101,31 @@ internal actual fun CollectionCardRemoteImage(
|
||||||
contentMode = UIViewContentMode.UIViewContentModeScaleAspectFill
|
contentMode = UIViewContentMode.UIViewContentModeScaleAspectFill
|
||||||
clipsToBounds = true
|
clipsToBounds = true
|
||||||
userInteractionEnabled = false
|
userInteractionEnabled = false
|
||||||
image = gifImage
|
|
||||||
tag = imageUrl.hashCode().toLong()
|
tag = imageUrl.hashCode().toLong()
|
||||||
|
imageViewHolder.imageView = this
|
||||||
|
updateGifImage(gifImage)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = { imageView ->
|
update = { imageView ->
|
||||||
|
imageViewHolder.imageView = imageView
|
||||||
if (imageView.tag != imageUrl.hashCode().toLong()) {
|
if (imageView.tag != imageUrl.hashCode().toLong()) {
|
||||||
imageView.tag = imageUrl.hashCode().toLong()
|
imageView.tag = imageUrl.hashCode().toLong()
|
||||||
}
|
}
|
||||||
imageView.image = gifImage
|
imageView.updateGifImage(gifImage)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun UIImageView.updateGifImage(image: UIImage?) {
|
||||||
|
if (this.image != image) {
|
||||||
|
stopAnimating()
|
||||||
|
this.image = image
|
||||||
|
}
|
||||||
|
if (image != null) {
|
||||||
|
startAnimating()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun cachedGifImage(imageUrl: String): UIImage? {
|
private fun cachedGifImage(imageUrl: String): UIImage? {
|
||||||
val image = gifImageCache[imageUrl] ?: return null
|
val image = gifImageCache[imageUrl] ?: return null
|
||||||
gifImageCacheOrder.remove(imageUrl)
|
gifImageCacheOrder.remove(imageUrl)
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
package com.nuvio.app.features.trakt
|
package com.nuvio.app.features.trakt
|
||||||
|
|
||||||
import platform.Foundation.NSDate
|
import platform.Foundation.NSDate
|
||||||
import platform.Foundation.NSISO8601DateFormatter
|
|
||||||
import platform.Foundation.timeIntervalSince1970
|
import platform.Foundation.timeIntervalSince1970
|
||||||
|
|
||||||
internal actual object TraktPlatformClock {
|
internal actual object TraktPlatformClock {
|
||||||
actual fun nowEpochMs(): Long = (NSDate().timeIntervalSince1970 * 1000.0).toLong()
|
actual fun nowEpochMs(): Long = (NSDate().timeIntervalSince1970 * 1000.0).toLong()
|
||||||
|
|
||||||
actual fun parseIsoDateTimeToEpochMs(value: String): Long? =
|
actual fun parseIsoDateTimeToEpochMs(value: String): Long? =
|
||||||
NSISO8601DateFormatter()
|
parseTraktIsoDateTimeToEpochMs(value)
|
||||||
.dateFromString(value)
|
|
||||||
?.let { date -> (date.timeIntervalSince1970 * 1000.0).toLong() }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
CURRENT_PROJECT_VERSION=54
|
CURRENT_PROJECT_VERSION=56
|
||||||
MARKETING_VERSION=0.1.0
|
MARKETING_VERSION=0.1.17
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue