Merge branch 'cmp-rewrite' into patch-25

This commit is contained in:
AdityasahuX07 2026-05-12 15:17:30 +05:30 committed by GitHub
commit 4dc054e51e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 4568 additions and 313 deletions

View file

@ -260,6 +260,7 @@ kotlin {
commonMain.dependencies {
implementation(libs.coil.compose)
implementation(libs.coil.network.ktor3)
implementation(libs.coil.svg)
implementation("dev.chrisbanes.haze:haze:1.7.2")
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
package com.nuvio.app.features.profiles
internal actual object ProfileHoverHapticFeedback {
actual fun prepare() = Unit
actual fun perform() = Unit
actual fun release() = Unit
}

View file

@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import com.nuvio.app.R
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.introdb_favicon
import nuvio.composeapp.generated.resources.rating_tmdb
import org.jetbrains.compose.resources.painterResource as composePainterResource
@ -14,4 +15,5 @@ internal actual fun integrationLogoPainter(logo: IntegrationLogo): Painter =
IntegrationLogo.Tmdb -> composePainterResource(Res.drawable.rating_tmdb)
IntegrationLogo.Trakt -> painterResource(id = R.drawable.trakt_tv_favicon)
IntegrationLogo.MdbList -> painterResource(id = R.drawable.mdblist_logo)
IntegrationLogo.IntroDb -> composePainterResource(Res.drawable.introdb_favicon)
}

View file

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

View file

@ -9,4 +9,5 @@
<locale android:name="el"/>
<locale android:name="pl"/>
<locale android:name="de"/>
<locale android:name="cs"/>
</locale-config>

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
<resources>
<string name="about_licenses_attributions_subtitle">Data sources, acknowledgements, and platform licenses</string>
<string name="about_supporters_contributors_subtitle">Open recognition and project credits</string>
<string name="action_back">Back</string>
<string name="action_cancel">Cancel</string>
@ -366,6 +367,7 @@
<string name="compose_settings_page_continue_watching">Continue Watching</string>
<string name="compose_settings_page_homescreen">Home Layout</string>
<string name="compose_settings_page_integrations">Integrations</string>
<string name="compose_settings_page_licenses_attributions">Licenses &amp; Attribution</string>
<string name="compose_settings_page_mdblist_ratings">MDBList Ratings</string>
<string name="compose_settings_page_meta_screen">Detail Page</string>
<string name="compose_settings_page_notifications">Notifications</string>
@ -391,6 +393,31 @@
<string name="compose_settings_root_switch_profile_description">Change to a different profile.</string>
<string name="compose_settings_root_switch_profile_title">Switch Profile</string>
<string name="compose_settings_root_trakt_description">Open Trakt connection screen</string>
<string name="settings_search_empty">No settings found.</string>
<string name="settings_search_placeholder">Search settings...</string>
<string name="settings_search_results_section">RESULTS</string>
<string name="settings_licenses_attributions_section_app">APP LICENSE</string>
<string name="settings_licenses_attributions_section_data">DATA &amp; SERVICES</string>
<string name="settings_licenses_attributions_section_playback">PLAYBACK LICENSE</string>
<string name="settings_licenses_attributions_nuvio_title">Nuvio Mobile</string>
<string name="settings_licenses_attributions_nuvio_body">Source code and license terms are available in the project repository.</string>
<string name="settings_licenses_attributions_nuvio_license">Licensed under the GNU General Public License v3.0.</string>
<string name="settings_licenses_attributions_tmdb_title">The Movie Database (TMDB)</string>
<string name="settings_licenses_attributions_tmdb_body">Nuvio uses the TMDB API for movie and TV metadata, artwork, trailers, cast, production details, collections, and recommendations. This product uses the TMDB API but is not endorsed or certified by TMDB.</string>
<string name="settings_licenses_attributions_imdb_title">IMDb Non-Commercial Datasets</string>
<string name="settings_licenses_attributions_imdb_body">Nuvio uses IMDb Non-Commercial Datasets, including title.ratings.tsv.gz, for IMDb ratings and vote counts. Information courtesy of IMDb (https://www.imdb.com). Used with permission. IMDb data is for personal and non-commercial use under IMDb&apos;s terms.</string>
<string name="settings_licenses_attributions_trakt_title">Trakt</string>
<string name="settings_licenses_attributions_trakt_body">Nuvio connects to Trakt for account authentication, watched history, progress sync, library data, ratings, lists, and comments. Nuvio is not affiliated with or endorsed by Trakt.</string>
<string name="settings_licenses_attributions_mdblist_title">MDBList</string>
<string name="settings_licenses_attributions_mdblist_body">Nuvio uses MDBList for ratings and external score provider data. Nuvio is not affiliated with or endorsed by MDBList.</string>
<string name="settings_licenses_attributions_introdb_title">IntroDB</string>
<string name="settings_licenses_attributions_introdb_body">Nuvio uses the IntroDB API for community-provided intro, recap, credits, and preview timestamps used by skip controls. Nuvio is not affiliated with or endorsed by IntroDB.</string>
<string name="settings_licenses_attributions_mpvkit_title">MPVKit</string>
<string name="settings_licenses_attributions_mpvkit_body">Used for playback on iOS builds.</string>
<string name="settings_licenses_attributions_mpvkit_license">MPVKit source alone is licensed under LGPL v3.0. MPVKit bundles, including libmpv and FFmpeg libraries, are also licensed under LGPL v3.0.</string>
<string name="settings_licenses_attributions_exoplayer_title">AndroidX Media3 ExoPlayer 1.8.0</string>
<string name="settings_licenses_attributions_exoplayer_body">Used for playback on Android builds.</string>
<string name="settings_licenses_attributions_exoplayer_license">Licensed under the Apache License, Version 2.0.</string>
<string name="compose_trakt_list_picker_loading">Loading your Trakt lists…</string>
<string name="compose_trakt_list_picker_subtitle">Choose where to save this title on Trakt</string>
<string name="action_donate">Donate</string>
@ -479,6 +506,8 @@
<string name="settings_homescreen_show_hero_description">Display hero carousel at top of home.</string>
<string name="layout_hide_unreleased">Hide Unreleased Content</string>
<string name="layout_hide_unreleased_sub">Hide movies and shows that haven't been released yet.</string>
<string name="settings_homescreen_hide_catalog_underline">Hide Catalog Underline</string>
<string name="settings_homescreen_hide_catalog_underline_description">Remove the accent line under catalog and collection titles throughout the app.</string>
<string name="settings_homescreen_summary">%1$d of %2$d catalogs visible • %3$d hero sources selected</string>
<string name="settings_homescreen_summary_hint">Open a catalog only when you need to rename or reorder it.</string>
<string name="settings_homescreen_visible">Visible</string>
@ -514,6 +543,12 @@
<string name="settings_continue_watching_blur_next_up_title">Blur Unwatched in Continue Watching</string>
<string name="settings_continue_watching_show_unaired_next_up_description">Include upcoming episodes in Continue Watching before they air.</string>
<string name="settings_continue_watching_show_unaired_next_up_title">Show Unaired Next Up Episodes</string>
<string name="settings_continue_watching_section_sort_order">SORT ORDER</string>
<string name="settings_continue_watching_sort_mode_title">Sort Order</string>
<string name="settings_continue_watching_sort_mode_default">Default</string>
<string name="settings_continue_watching_sort_mode_default_desc">Sort all items by recency</string>
<string name="settings_continue_watching_sort_mode_streaming">Streaming Style</string>
<string name="settings_continue_watching_sort_mode_streaming_desc">Released items first, upcoming at the end</string>
<string name="settings_continue_watching_section_card_style">Poster Card Style</string>
<string name="settings_continue_watching_section_on_launch">ON LAUNCH</string>
<string name="settings_continue_watching_section_up_next_behavior">UP NEXT BEHAVIOR</string>
@ -1111,6 +1146,7 @@
<string name="downloads_live_failed">Download failed</string>
<string name="downloads_live_paused">Paused %1$s</string>
<string name="library_remove_confirm">Remove</string>
<string name="library_remove_from_list_message">Remove %1$s from %2$s?</string>
<string name="library_remove_message">Remove %1$s from your library?</string>
<string name="library_remove_title">Remove from Library?</string>
<string name="media_movie">Movie</string>

View file

@ -73,6 +73,7 @@ import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory
import coil3.request.CachePolicy
import coil3.request.crossfade
import coil3.svg.SvgDecoder
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
@ -146,6 +147,7 @@ import com.nuvio.app.features.settings.AddonsSettingsScreen
import com.nuvio.app.features.settings.PluginsSettingsScreen
import com.nuvio.app.features.settings.AccountSettingsScreen
import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen
import com.nuvio.app.features.settings.LicensesAttributionsSettingsScreen
import com.nuvio.app.features.settings.ThemeSettingsRepository
import com.nuvio.app.features.collection.CollectionManagementScreen
import com.nuvio.app.features.collection.CollectionEditorScreen
@ -238,6 +240,9 @@ object AccountSettingsRoute
@Serializable
object SupportersContributorsSettingsRoute
@Serializable
object LicensesAttributionsSettingsRoute
@Serializable
object CollectionsRoute
@ -301,6 +306,9 @@ fun App() {
.crossfade(true)
.diskCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.components {
add(SvgDecoder.Factory())
}
.configurePlatformImageLoader()
.build()
}
@ -513,6 +521,7 @@ private fun MainAppContent(
val hapticFeedback = LocalHapticFeedback.current
val coroutineScope = rememberCoroutineScope()
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle()
val liquidGlassNativeTabBarEnabled by remember {
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
@ -1005,6 +1014,7 @@ private fun MainAppContent(
val isTabletLayout = maxWidth >= 768.dp
val useNativeBottomTabs =
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute<TabsRoute>() == true
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
profileSwitchLoading = true
selectedTab = AppScreenTab.Home
@ -1063,6 +1073,7 @@ private fun MainAppContent(
.fillMaxSize()
.padding(innerPadding),
selectedTab = selectedTab,
animateHomeCollectionGifs = tabsRouteActive,
onCatalogClick = onCatalogClick,
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
@ -1092,6 +1103,9 @@ private fun MainAppContent(
onSupportersContributorsSettingsClick = {
navController.navigate(SupportersContributorsSettingsRoute)
},
onLicensesAttributionsSettingsClick = {
navController.navigate(LicensesAttributionsSettingsRoute)
},
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
{
appUpdaterController.checkForUpdates(
@ -1354,7 +1368,13 @@ private fun MainAppContent(
reuseHandled = true
if (launch.manualSelection) return@LaunchedEffect
if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect
val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId)
val cacheKey = StreamLinkCacheRepository.contentKey(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
)
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
if (cached != null) {
@ -1394,17 +1414,37 @@ private fun MainAppContent(
}
val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
val expectedStreamsRequestToken = StreamsRepository.requestToken(
type = launch.type,
videoId = effectiveVideoId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
manualSelection = launch.manualSelection,
)
var autoPlayHandled by rememberSaveable(launch.videoId, effectiveVideoId) { mutableStateOf(false) }
LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled, launch.manualSelection) {
LaunchedEffect(
streamsUiState.autoPlayStream,
streamsUiState.requestToken,
expectedStreamsRequestToken,
reuseHandled,
launch.manualSelection,
) {
if (!reuseHandled) return@LaunchedEffect
if (launch.manualSelection) return@LaunchedEffect
if (reuseNavigated) return@LaunchedEffect
if (autoPlayHandled) return@LaunchedEffect
if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect
val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect
autoPlayHandled = true
if (playerSettings.streamReuseLastLinkEnabled) {
val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId)
val cacheKey = StreamLinkCacheRepository.contentKey(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
)
StreamLinkCacheRepository.save(
contentKey = cacheKey,
url = sourceUrl,
@ -1484,7 +1524,13 @@ private fun MainAppContent(
if (sourceUrl != null) {
// Persist for Reuse Last Link
if (playerSettings.streamReuseLastLinkEnabled) {
val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId)
val cacheKey = StreamLinkCacheRepository.contentKey(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
)
StreamLinkCacheRepository.save(
contentKey = cacheKey,
url = sourceUrl,
@ -1725,6 +1771,15 @@ private fun MainAppContent(
onBack = onBack,
)
}
composable<LicensesAttributionsSettingsRoute> { backStackEntry ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
backStackEntry = backStackEntry,
)
LicensesAttributionsSettingsScreen(
onBack = onBack,
)
}
composable<CollectionsRoute> { backStackEntry ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
@ -2003,6 +2058,7 @@ private fun rememberGuardedPopBackStack(
private fun AppTabHost(
selectedTab: AppScreenTab,
modifier: Modifier = Modifier,
animateHomeCollectionGifs: Boolean = true,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
@ -2020,6 +2076,7 @@ private fun AppTabHost(
onPluginsSettingsClick: () -> Unit = {},
onAccountSettingsClick: () -> Unit = {},
onSupportersContributorsSettingsClick: () -> Unit = {},
onLicensesAttributionsSettingsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsSettingsClick: () -> Unit = {},
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
@ -2033,6 +2090,7 @@ private fun AppTabHost(
AppScreenTab.Home -> {
HomeScreen(
modifier = Modifier.fillMaxSize(),
animateCollectionGifs = animateHomeCollectionGifs,
onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
@ -2072,6 +2130,7 @@ private fun AppTabHost(
onPluginsClick = onPluginsSettingsClick,
onAccountClick = onAccountSettingsClick,
onSupportersContributorsClick = onSupportersContributorsSettingsClick,
onLicensesAttributionsClick = onLicensesAttributionsSettingsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsSettingsClick,
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -98,11 +98,14 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
// Fallback: if the seed wasn't found by season+episode (anime with absolute
// numbering on Trakt vs multi-season on addon), try global index matching.
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.season }
val mainEpisodes = sortedEpisodes.filter { episode -> normalizeSeasonNumber(episode.season) > 0 }
val addonSeasons = mainEpisodes.mapTo(mutableSetOf()) { episode ->
normalizeSeasonNumber(episode.season)
}
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
val globalIndex = episodeNumber - 1
if (globalIndex in sortedEpisodes.indices) {
watchedIndex = globalIndex
if (globalIndex in mainEpisodes.indices) {
watchedIndex = sortedEpisodes.indexOf(mainEpisodes[globalIndex])
}
}
}

View file

@ -33,6 +33,7 @@ data class HomeCatalogSettingsItem(
data class HomeCatalogSettingsUiState(
val heroEnabled: Boolean = true,
val hideUnreleasedContent: Boolean = false,
val hideCatalogUnderline: Boolean = false,
val items: List<HomeCatalogSettingsItem> = emptyList(),
) {
val signature: String
@ -41,6 +42,8 @@ data class HomeCatalogSettingsUiState(
append('|')
append(hideUnreleasedContent)
append('|')
append(hideCatalogUnderline)
append('|')
append(
items.joinToString(separator = "|") { item ->
"${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}"
@ -59,6 +62,7 @@ internal data class HomeCatalogPreference(
internal data class HomeCatalogSettingsSnapshot(
val heroEnabled: Boolean,
val hideUnreleasedContent: Boolean,
val hideCatalogUnderline: Boolean,
val preferences: Map<String, HomeCatalogPreference>,
)
@ -75,6 +79,7 @@ private data class StoredHomeCatalogPreference(
private data class StoredHomeCatalogSettingsPayload(
val heroEnabled: Boolean = true,
val hideUnreleasedContent: Boolean = false,
val hideCatalogUnderline: Boolean = false,
val items: List<StoredHomeCatalogPreference> = emptyList(),
)
@ -95,12 +100,14 @@ object HomeCatalogSettingsRepository {
private var preferences: MutableMap<String, StoredHomeCatalogPreference> = mutableMapOf()
private var heroEnabled = true
private var hideUnreleasedContent = false
private var hideCatalogUnderline = false
fun onProfileChanged() {
hasLoaded = false
preferences.clear()
heroEnabled = true
hideUnreleasedContent = false
hideCatalogUnderline = false
definitions = emptyList()
collectionDefinitions = emptyList()
_uiState.value = HomeCatalogSettingsUiState()
@ -113,6 +120,7 @@ object HomeCatalogSettingsRepository {
preferences.clear()
heroEnabled = true
hideUnreleasedContent = false
hideCatalogUnderline = false
_uiState.value = HomeCatalogSettingsUiState()
}
@ -144,6 +152,7 @@ object HomeCatalogSettingsRepository {
return HomeCatalogSettingsSnapshot(
heroEnabled = heroEnabled,
hideUnreleasedContent = hideUnreleasedContent,
hideCatalogUnderline = hideCatalogUnderline,
preferences = preferences.mapValues { (_, value) ->
HomeCatalogPreference(
customTitle = value.customTitle,
@ -172,6 +181,14 @@ object HomeCatalogSettingsRepository {
HomeRepository.applyCurrentSettings()
}
fun setHideCatalogUnderline(enabled: Boolean) {
ensureLoaded()
if (hideCatalogUnderline == enabled) return
hideCatalogUnderline = enabled
publish()
persist()
}
fun setHeroSourceEnabled(key: String, enabled: Boolean) {
updatePreference(key) { preference ->
if (!enabled) {
@ -200,6 +217,7 @@ object HomeCatalogSettingsRepository {
ensureLoaded()
heroEnabled = true
hideUnreleasedContent = false
hideCatalogUnderline = false
preferences.clear()
normalizePreferences()
publish()
@ -246,6 +264,7 @@ object HomeCatalogSettingsRepository {
if (parsedPayload != null) {
heroEnabled = parsedPayload.heroEnabled
hideUnreleasedContent = parsedPayload.hideUnreleasedContent
hideCatalogUnderline = parsedPayload.hideCatalogUnderline
preferences = parsedPayload.items.associateBy { it.key }.toMutableMap()
publish()
return
@ -345,6 +364,7 @@ object HomeCatalogSettingsRepository {
_uiState.value = HomeCatalogSettingsUiState(
heroEnabled = heroEnabled,
hideUnreleasedContent = hideUnreleasedContent,
hideCatalogUnderline = hideCatalogUnderline,
items = items,
)
}
@ -355,6 +375,7 @@ object HomeCatalogSettingsRepository {
StoredHomeCatalogSettingsPayload(
heroEnabled = heroEnabled,
hideUnreleasedContent = hideUnreleasedContent,
hideCatalogUnderline = hideCatalogUnderline,
items = preferences.values.sortedBy { it.order },
),
),
@ -437,6 +458,7 @@ object HomeCatalogSettingsRepository {
}
return SyncHomeCatalogPayload(
hideUnreleasedContent = hideUnreleasedContent,
hideCatalogUnderline = hideCatalogUnderline,
items = items,
)
}
@ -444,6 +466,7 @@ object HomeCatalogSettingsRepository {
fun applyFromRemote(payload: SyncHomeCatalogPayload) {
ensureLoaded()
hideUnreleasedContent = payload.hideUnreleasedContent
hideCatalogUnderline = payload.hideCatalogUnderline
if (payload.items.isNotEmpty()) {
val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled }
preferences = payload.items.associate { item ->

View file

@ -42,6 +42,7 @@ data class SyncCatalogItem(
@Serializable
data class SyncHomeCatalogPayload(
@SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false,
@SerialName("hide_catalog_underline") val hideCatalogUnderline: Boolean = false,
val items: List<SyncCatalogItem> = emptyList(),
)

View file

@ -42,6 +42,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode
import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching
import com.nuvio.app.features.watchprogress.nextUpDismissKey
import com.nuvio.app.features.watchprogress.WatchProgressClock
@ -70,6 +71,7 @@ import org.jetbrains.compose.resources.stringResource
@Composable
fun HomeScreen(
modifier: Modifier = Modifier,
animateCollectionGifs: Boolean = true,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
@ -246,11 +248,14 @@ fun HomeScreen(
visibleContinueWatchingEntries,
cachedInProgressItems,
effectivNextUpItems,
continueWatchingPreferences.sortMode,
) {
buildHomeContinueWatchingItems(
visibleEntries = visibleContinueWatchingEntries,
cachedInProgressByVideoId = cachedInProgressItems,
nextUpItemsBySeries = effectivNextUpItems,
sortMode = continueWatchingPreferences.sortMode,
todayIsoDate = CurrentDateProvider.todayIsoDate(),
)
}
val availableManifests = remember(addonsUiState.addons) {
@ -403,6 +408,11 @@ fun HomeScreen(
val enabledHomeItems = remember(homeSettingsUiState.items) {
homeSettingsUiState.items.filter { it.enabled }
}
val hasRenderableCollectionRows = remember(enabledHomeItems, collectionsMap) {
enabledHomeItems.any { item ->
item.isCollection && collectionsMap[item.key] != null
}
}
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value)
@ -465,7 +475,7 @@ fun HomeScreen(
}
when {
addonsUiState.addons.none { it.manifest != null } -> {
addonsUiState.addons.none { it.manifest != null } && !hasRenderableCollectionRows -> {
if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) {
item {
HomeContinueWatchingSection(
@ -490,7 +500,7 @@ fun HomeScreen(
}
}
homeUiState.isLoading && homeUiState.sections.isEmpty() -> {
homeUiState.isLoading && homeUiState.sections.isEmpty() && !hasRenderableCollectionRows -> {
if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) {
item {
HomeContinueWatchingSection(
@ -512,7 +522,8 @@ fun HomeScreen(
}
homeUiState.sections.isEmpty() && homeUiState.heroItems.isEmpty() &&
(!continueWatchingPreferences.isVisible || continueWatchingItems.isEmpty()) -> {
(!continueWatchingPreferences.isVisible || continueWatchingItems.isEmpty()) &&
!hasRenderableCollectionRows -> {
item {
if (networkStatusUiState.isOfflineLike) {
NuvioNetworkOfflineCard(
@ -560,6 +571,7 @@ fun HomeScreen(
collection = collection,
modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding,
animateGifs = animateCollectionGifs,
onFolderClick = onFolderClick,
)
}
@ -631,6 +643,8 @@ internal fun buildHomeContinueWatchingItems(
visibleEntries: List<WatchProgressEntry>,
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
todayIsoDate: String = "",
): List<ContinueWatchingItem> {
val inProgressSeriesIds = visibleEntries
.asSequence()
@ -639,7 +653,7 @@ internal fun buildHomeContinueWatchingItems(
.filter(String::isNotBlank)
.toSet()
return buildList {
val candidates = buildList {
addAll(
visibleEntries.map { entry ->
val liveItem = entry.toContinueWatchingItem()
@ -661,13 +675,62 @@ internal fun buildHomeContinueWatchingItems(
},
)
}
// Deduplicate by series/content id first (order-stable)
val seen = mutableSetOf<String>()
val deduplicated = candidates
.sortedWith(
compareByDescending<HomeContinueWatchingCandidate> { it.lastUpdatedEpochMs }
.thenByDescending { it.isProgressEntry },
)
.filter { candidate -> candidate.item.shouldDisplayInContinueWatching() }
.distinctBy { candidate -> candidate.item.parentMetaId.ifBlank { candidate.item.videoId } }
.filter { candidate ->
val key = candidate.item.parentMetaId.ifBlank { candidate.item.videoId }
seen.add(key)
}
return when (sortMode) {
ContinueWatchingSortMode.DEFAULT -> deduplicated.map(HomeContinueWatchingCandidate::item)
ContinueWatchingSortMode.STREAMING_STYLE -> applyStreamingStyleSort(deduplicated, todayIsoDate)
}
}
private fun applyStreamingStyleSort(
candidates: List<HomeContinueWatchingCandidate>,
todayIsoDate: String,
): List<ContinueWatchingItem> {
val (released, unreleased) = candidates.partition { candidate ->
val item = candidate.item
if (!item.isNextUp) {
true // in-progress items are always "released"
} else {
val itemReleased = item.released
if (itemReleased.isNullOrBlank() || todayIsoDate.isBlank()) {
true // no date info → treat as released
} else {
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = itemReleased)
}
}
}
// Released: most recently watched first (already sorted by dedup pass)
val sortedReleased = released.map(HomeContinueWatchingCandidate::item)
// Unaired: soonest air date first; unknown dates go to the end
val sortedUnreleased = unreleased
.sortedWith { a, b ->
val dateA = a.item.released?.takeIf { it.isNotBlank() }
val dateB = b.item.released?.takeIf { it.isNotBlank() }
when {
dateA == null && dateB == null -> 0
dateA == null -> 1
dateB == null -> -1
else -> dateA.compareTo(dateB)
}
}
.map(HomeContinueWatchingCandidate::item)
return sortedReleased + sortedUnreleased
}
private data class CompletedSeriesCandidate(

View file

@ -4,11 +4,15 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.NuvioViewAllPillSize
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.stableKey
@ -64,6 +68,10 @@ private fun HomeCatalogRowSectionContent(
onPosterLongClick: ((MetaPreview) -> Unit)?,
) {
val posterCardStyle = rememberPosterCardStyleUiState()
val homeCatalogSettings by remember {
HomeCatalogSettingsRepository.snapshot()
HomeCatalogSettingsRepository.uiState
}.collectAsStateWithLifecycle()
NuvioShelfSection(
title = section.title,
@ -71,6 +79,7 @@ private fun HomeCatalogRowSectionContent(
modifier = modifier,
headerHorizontalPadding = sectionPadding,
rowContentPadding = PaddingValues(horizontal = sectionPadding),
showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline,
onViewAllClick = onViewAllClick,
viewAllPillSize = NuvioViewAllPillSize.Compact,
key = { item -> item.stableKey() },

View file

@ -15,6 +15,8 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
@ -23,6 +25,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.PosterLandscapeAspectRatio
import com.nuvio.app.core.ui.landscapePosterWidth
@ -30,6 +33,7 @@ import com.nuvio.app.core.ui.posterCardClickable
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
import com.nuvio.app.features.collection.Collection
import com.nuvio.app.features.collection.CollectionFolder
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.PosterShape
@Composable
@ -37,6 +41,7 @@ fun HomeCollectionRowSection(
collection: Collection,
modifier: Modifier = Modifier,
sectionPadding: Dp? = null,
animateGifs: Boolean = true,
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
) {
if (collection.folders.isEmpty()) return
@ -46,6 +51,7 @@ fun HomeCollectionRowSection(
collection = collection,
modifier = modifier.fillMaxWidth(),
sectionPadding = sectionPadding,
animateGifs = animateGifs,
onFolderClick = onFolderClick,
)
} else {
@ -54,6 +60,7 @@ fun HomeCollectionRowSection(
collection = collection,
modifier = Modifier.fillMaxWidth(),
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
animateGifs = animateGifs,
onFolderClick = onFolderClick,
)
}
@ -65,18 +72,26 @@ private fun HomeCollectionRowSectionContent(
collection: Collection,
modifier: Modifier,
sectionPadding: Dp,
animateGifs: Boolean,
onFolderClick: ((collectionId: String, folderId: String) -> Unit)?,
) {
val homeCatalogSettings by remember {
HomeCatalogSettingsRepository.snapshot()
HomeCatalogSettingsRepository.uiState
}.collectAsStateWithLifecycle()
NuvioShelfSection(
title = collection.title,
entries = collection.folders,
modifier = modifier,
headerHorizontalPadding = sectionPadding,
rowContentPadding = PaddingValues(horizontal = sectionPadding),
showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline,
key = { folder -> "collection_${collection.id}_folder_${folder.id}" },
) { folder ->
CollectionFolderCard(
folder = folder,
animateGifs = animateGifs,
onClick = onFolderClick?.let { { it(collection.id, folder.id) } },
)
}
@ -86,6 +101,7 @@ private fun HomeCollectionRowSectionContent(
private fun CollectionFolderCard(
folder: CollectionFolder,
modifier: Modifier = Modifier,
animateGifs: Boolean = true,
onClick: (() -> Unit)? = null,
) {
val posterCardStyle = rememberPosterCardStyleUiState()
@ -138,7 +154,7 @@ private fun CollectionFolderCard(
contentDescription = folder.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
animateIfPossible = isAnimatedCollectionFolderImage(folder, imageUrl),
animateIfPossible = animateGifs && isAnimatedCollectionFolderImage(folder, imageUrl),
)
}
!folder.coverEmoji.isNullOrBlank() -> {
@ -180,7 +196,7 @@ private fun CollectionFolderCard(
}
private fun collectionFolderCardImageUrl(folder: CollectionFolder): String? {
return if (folder.focusGifEnabled) {
return if (folder.mobileFocusGifEnabled) {
firstNonBlank(folder.focusGifUrl, folder.coverImageUrl)
} else {
firstNonBlank(folder.coverImageUrl)
@ -196,5 +212,5 @@ private fun isAnimatedCollectionFolderImage(
imageUrl: String,
): Boolean {
val gifUrl = firstNonBlank(folder.focusGifUrl) ?: return false
return folder.focusGifEnabled && imageUrl == gifUrl
return folder.mobileFocusGifEnabled && imageUrl == gifUrl
}

View file

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

View file

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

View file

@ -357,9 +357,10 @@ fun PlayerScreen(
.coerceIn(0f, 100f)
}
fun currentTraktScrobbleItem() = TraktScrobbleRepository.buildItem(
suspend fun currentTraktScrobbleItem() = TraktScrobbleRepository.buildItem(
contentType = contentType ?: parentMetaType,
parentMetaId = parentMetaId,
videoId = activeVideoId,
title = title,
seasonNumber = activeSeasonNumber,
episodeNumber = activeEpisodeNumber,
@ -367,11 +368,15 @@ fun PlayerScreen(
)
fun emitTraktScrobbleStart() {
val item = currentTraktScrobbleItem() ?: return
if (hasRequestedScrobbleStartForCurrentItem) return
hasRequestedScrobbleStartForCurrentItem = true
scope.launch {
val item = currentTraktScrobbleItem()
if (item == null) {
hasRequestedScrobbleStartForCurrentItem = false
return@launch
}
TraktScrobbleRepository.scrobbleStart(
item = item,
progressPercent = currentPlaybackProgressPercent(),
@ -380,12 +385,12 @@ fun PlayerScreen(
}
fun emitTraktScrobbleStop(progressPercent: Float? = null) {
val item = currentTraktScrobbleItem() ?: return
val provided = progressPercent
if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return
val percent = provided ?: currentPlaybackProgressPercent()
scope.launch {
val item = currentTraktScrobbleItem() ?: return@launch
TraktScrobbleRepository.scrobbleStop(
item = item,
progressPercent = percent,
@ -786,8 +791,11 @@ fun PlayerScreen(
flushWatchProgress()
if (playerSettingsUiState.streamReuseLastLinkEnabled && activeVideoId != null) {
val cacheKey = StreamLinkCacheRepository.contentKey(
contentType ?: parentMetaType,
activeVideoId!!,
type = contentType ?: parentMetaType,
videoId = activeVideoId!!,
parentMetaId = parentMetaId,
season = activeSeasonNumber,
episode = activeEpisodeNumber,
)
StreamLinkCacheRepository.save(
contentKey = cacheKey,
@ -846,8 +854,11 @@ fun PlayerScreen(
val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L
if (playerSettingsUiState.streamReuseLastLinkEnabled) {
val cacheKey = StreamLinkCacheRepository.contentKey(
contentType ?: parentMetaType,
epVideoId,
type = contentType ?: parentMetaType,
videoId = epVideoId,
parentMetaId = parentMetaId,
season = episode.season,
episode = episode.episode,
)
StreamLinkCacheRepository.save(
contentKey = cacheKey,
@ -1449,12 +1460,15 @@ fun PlayerScreen(
totalDy += delta.y
if (gestureMode == null) {
val holdToSpeedActive = isHoldToSpeedGestureActiveState.value
val horizontalDominant =
!isHoldToSpeedGestureActiveState.value &&
!holdToSpeedActive &&
abs(totalDx) > viewConfiguration.touchSlop &&
abs(totalDx) > abs(totalDy)
val verticalDominant =
abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx)
!holdToSpeedActive &&
abs(totalDy) > viewConfiguration.touchSlop &&
abs(totalDy) > abs(totalDx)
gestureMode = when {
horizontalDominant -> {
@ -1555,8 +1569,11 @@ fun PlayerScreen(
val currentVideoId = activeVideoId
if (currentVideoId != null) {
val cacheKey = StreamLinkCacheRepository.contentKey(
contentType ?: parentMetaType,
currentVideoId,
type = contentType ?: parentMetaType,
videoId = currentVideoId,
parentMetaId = parentMetaId,
season = activeSeasonNumber,
episode = activeEpisodeNumber,
)
StreamLinkCacheRepository.remove(cacheKey)
}

View file

@ -0,0 +1,7 @@
package com.nuvio.app.features.profiles
internal expect object ProfileHoverHapticFeedback {
fun prepare()
fun perform()
fun release()
}

View file

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

View file

@ -14,7 +14,7 @@ import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -40,6 +40,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -48,10 +49,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
@ -64,6 +70,7 @@ import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.nuvio.app.isIos
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
@ -97,6 +104,52 @@ fun ProfileSwitcherTab(
// Keep popup composed while exit animation plays
var popupVisible by remember { mutableStateOf(false) }
var pinProfile by remember { mutableStateOf<NuvioProfile?>(null) }
var dragTargetProfileIndex by remember { mutableStateOf<Int?>(null) }
var triggerCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
val profileBubbleBounds = remember(profiles.map { it.profileIndex }) {
mutableStateMapOf<Int, Rect>()
}
fun performProfileHoldHaptic() {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
fun performProfileHoverHaptic() {
if (isIos) {
ProfileHoverHapticFeedback.perform()
} else {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}
fun updateDragTarget(localPosition: Offset) {
val trigger = triggerCoordinates ?: return
val windowPosition = trigger.localToWindow(localPosition)
val nextTargetProfileIndex = profileBubbleBounds.entries
.firstOrNull { (_, bounds) -> bounds.contains(windowPosition) }
?.key
if (nextTargetProfileIndex != null && nextTargetProfileIndex != dragTargetProfileIndex) {
performProfileHoverHaptic()
}
dragTargetProfileIndex = nextTargetProfileIndex
}
fun chooseProfile(profile: NuvioProfile) {
if (profile.pinEnabled) {
pinProfile = profile
} else {
onProfileSelected(profile)
showPopup = false
}
}
fun chooseDragTarget() {
val profile = profiles.firstOrNull { it.profileIndex == dragTargetProfileIndex }
dragTargetProfileIndex = null
if (profile != null) {
chooseProfile(profile)
}
}
// Popup entrance/exit animation
val popupAlpha = remember { Animatable(0f) }
@ -126,6 +179,7 @@ fun ProfileSwitcherTab(
)
}
} else {
ProfileHoverHapticFeedback.release()
// Animate out
launch { popupAlpha.animateTo(0f, tween(180, easing = FastOutSlowInEasing)) }
launch { popupScale.animateTo(0.85f, tween(200, easing = FastOutSlowInEasing)) }
@ -134,21 +188,41 @@ fun ProfileSwitcherTab(
// Remove from composition after animation completes
popupVisible = false
pinProfile = null
dragTargetProfileIndex = null
}
}
}
Box(
modifier = modifier
.onGloballyPositioned { triggerCoordinates = it }
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
)
.pointerInput(profiles) {
detectTapGestures(
onTap = { onClick() },
onLongPress = {
detectDragGesturesAfterLongPress(
onDragStart = { startOffset ->
if (profiles.isNotEmpty()) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
performProfileHoldHaptic()
ProfileHoverHapticFeedback.prepare()
showPopup = true
updateDragTarget(startOffset)
}
},
onDrag = { change, _ ->
change.consume()
updateDragTarget(change.position)
},
onDragEnd = {
ProfileHoverHapticFeedback.release()
chooseDragTarget()
},
onDragCancel = {
ProfileHoverHapticFeedback.release()
dragTargetProfileIndex = null
},
)
},
contentAlignment = Alignment.Center,
@ -199,20 +273,20 @@ fun ProfileSwitcherTab(
profile.profileIndex == activeProfile?.profileIndex
val isPinTarget =
pinProfile?.profileIndex == profile.profileIndex
val isDragTarget =
dragTargetProfileIndex == profile.profileIndex
PopupProfileBubble(
profile = profile,
avatars = avatars,
isActive = isActive,
isSelected = isPinTarget,
isSelected = isPinTarget || isDragTarget,
delayMs = index * 50,
onBoundsChanged = { bounds ->
profileBubbleBounds[profile.profileIndex] = bounds
},
onClick = {
if (profile.pinEnabled) {
pinProfile = profile
} else {
onProfileSelected(profile)
showPopup = false
}
chooseProfile(profile)
},
)
}
@ -335,6 +409,7 @@ private fun PopupProfileBubble(
isActive: Boolean,
isSelected: Boolean,
delayMs: Int,
onBoundsChanged: (Rect) -> Unit,
onClick: () -> Unit,
) {
val avatarColor = remember(profile.avatarColorHex) { parseHexColor(profile.avatarColorHex) }
@ -363,7 +438,7 @@ private fun PopupProfileBubble(
}
val pressScale by animateFloatAsState(
targetValue = if (isSelected) 1.15f else 1f,
targetValue = if (isSelected) 1.08f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow,
@ -374,6 +449,9 @@ private fun PopupProfileBubble(
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.onGloballyPositioned { coordinates ->
onBoundsChanged(coordinates.boundsInWindow())
}
.graphicsLayer {
alpha = itemAlpha.value
scaleX = itemScale.value * pressScale

View file

@ -20,11 +20,11 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
@ -91,16 +91,57 @@ object SearchRepository {
_uiState.value = SearchUiState(isLoading = true)
activeJob = scope.launch {
val results = requests.map { request ->
async {
val resultChannel = Channel<IndexedSearchResult>(Channel.UNLIMITED)
val jobs = requests.mapIndexed { index, request ->
launch {
runCatching { request.toSection() }
.fold(
onSuccess = { section ->
resultChannel.send(
IndexedSearchResult(
index = index,
section = section,
),
)
},
onFailure = { error ->
if (error is CancellationException) throw error
resultChannel.send(
IndexedSearchResult(
index = index,
error = error,
),
)
},
)
}
}.awaitAll()
}
val closeChannelJob = launch {
jobs.joinAll()
resultChannel.close()
}
val results = arrayOfNulls<IndexedSearchResult>(requests.size)
val sections = results
.mapNotNull { it.getOrNull() }
val firstFailure = results.firstNotNullOfOrNull { it.exceptionOrNull()?.message }
val allFailed = results.isNotEmpty() && results.all { it.isFailure }
try {
for (result in resultChannel) {
results[result.index] = result
val sections = results.orderedSections()
if (sections.isNotEmpty()) {
_uiState.value = SearchUiState(
isLoading = true,
sections = sections,
)
}
}
} finally {
closeChannelJob.cancel()
resultChannel.close()
}
val completedResults = results.filterNotNull()
val sections = results.orderedSections()
val firstFailure = completedResults.firstNotNullOfOrNull { it.error?.message }
val allFailed = completedResults.isNotEmpty() && completedResults.all { it.error != null }
_uiState.value = SearchUiState(
isLoading = false,
@ -436,6 +477,15 @@ object SearchRepository {
}
}
private data class IndexedSearchResult(
val index: Int,
val section: HomeCatalogSection? = null,
val error: Throwable? = null,
)
private fun Array<IndexedSearchResult?>.orderedSections(): List<HomeCatalogSection> =
mapNotNull { result -> result?.section }
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())

View file

@ -33,6 +33,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@ -220,7 +221,14 @@ fun SearchScreen(
androidx.compose.foundation.layout.Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background),
.background(MaterialTheme.colorScheme.background)
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent()
}
}
},
) {
NuvioScreenHeader(
title = headerTitle,
@ -277,53 +285,66 @@ fun SearchScreen(
onPosterLongClick = onPosterLongClick,
)
} else {
when {
uiState.isLoading && uiState.sections.isEmpty() -> {
items(2) {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
val normalizedQuery = query.trim()
val isWaitingForSearch = normalizedQuery.isNotBlank() && lastRequestedQuery != normalizedQuery
when {
isWaitingForSearch -> {
items(2) {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
}
}
}
uiState.sections.isEmpty() -> {
item {
SearchEmptyStateCard(
reason = uiState.emptyStateReason,
errorMessage = uiState.errorMessage,
networkCondition = networkStatusUiState.condition,
onRetry = {
val normalizedQuery = query.trim()
if (normalizedQuery.isNotBlank()) {
NetworkStatusRepository.requestRefresh(force = true)
SearchRepository.search(
query = normalizedQuery,
addons = addonsUiState.addons,
)
}
},
)
uiState.isLoading && uiState.sections.isEmpty() -> {
items(2) {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
}
}
}
else -> {
items(
items = uiState.sections.withDuplicateSafeLazyKeys { section -> section.key },
key = { section -> section.lazyKey },
) { keyedSection ->
val section = keyedSection.value
HomeCatalogRowSection(
section = section,
modifier = Modifier.padding(bottom = 12.dp),
watchedKeys = watchedUiState.watchedKeys,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
)
uiState.sections.isEmpty() -> {
item {
SearchEmptyStateCard(
reason = uiState.emptyStateReason,
errorMessage = uiState.errorMessage,
networkCondition = networkStatusUiState.condition,
onRetry = {
if (normalizedQuery.isNotBlank()) {
NetworkStatusRepository.requestRefresh(force = true)
SearchRepository.search(
query = normalizedQuery,
addons = addonsUiState.addons,
)
}
},
modifier = Modifier.padding(horizontal = homeSectionPadding),
)
}
}
else -> {
items(
items = uiState.sections.withDuplicateSafeLazyKeys { section -> section.key },
key = { section -> section.lazyKey },
) { keyedSection ->
val section = keyedSection.value
HomeCatalogRowSection(
section = section,
modifier = Modifier.padding(bottom = 12.dp),
watchedKeys = watchedUiState.watchedKeys,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
)
}
if (uiState.isLoading) {
item(key = "search_loading_more") {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
}
}
}
}
}
}
}
}
}
private fun discoverColumnCountForWidth(screenWidth: Dp): Int =
when {

View file

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

View file

@ -10,6 +10,7 @@ import nuvio.composeapp.generated.resources.lang_turkish
import nuvio.composeapp.generated.resources.lang_italian
import nuvio.composeapp.generated.resources.lang_greek
import nuvio.composeapp.generated.resources.lang_polish
import nuvio.composeapp.generated.resources.lang_czech
import org.jetbrains.compose.resources.StringResource
enum class AppLanguage(
@ -25,6 +26,7 @@ enum class AppLanguage(
ITALIAN("it", Res.string.lang_italian),
GREEK("el", Res.string.lang_greek),
POLISH("pl", Res.string.lang_polish),
CZECH("cs", Res.string.lang_czech),
;
companion object {

View file

@ -5,18 +5,27 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@ -25,6 +34,7 @@ import androidx.compose.ui.unit.dp
import com.nuvio.app.features.home.components.ContinueWatchingStylePreview
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_description
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title
@ -34,10 +44,16 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_show_unai
import nuvio.composeapp.generated.resources.settings_continue_watching_show_unaired_next_up_title
import nuvio.composeapp.generated.resources.settings_continue_watching_section_card_style
import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch
import nuvio.composeapp.generated.resources.settings_continue_watching_section_sort_order
import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior
import nuvio.composeapp.generated.resources.settings_continue_watching_section_visibility
import nuvio.composeapp.generated.resources.settings_continue_watching_show_description
import nuvio.composeapp.generated.resources.settings_continue_watching_show_title
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_default
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_default_desc
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming_desc
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_title
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster_description
import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide
@ -58,6 +74,7 @@ internal fun LazyListScope.continueWatchingSettingsContent(
showUnairedNextUp: Boolean,
blurNextUp: Boolean,
showResumePromptOnLaunch: Boolean,
sortMode: ContinueWatchingSortMode,
) {
item {
SettingsSection(
@ -145,6 +162,39 @@ internal fun LazyListScope.continueWatchingSettingsContent(
}
}
}
item {
var showSortModeSheet by remember { mutableStateOf(false) }
SettingsSection(
title = stringResource(Res.string.settings_continue_watching_section_sort_order),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
val currentModeLabel = stringResource(
when (sortMode) {
ContinueWatchingSortMode.DEFAULT -> Res.string.settings_continue_watching_sort_mode_default
ContinueWatchingSortMode.STREAMING_STYLE -> Res.string.settings_continue_watching_sort_mode_streaming
}
)
SettingsNavigationRow(
title = stringResource(Res.string.settings_continue_watching_sort_mode_title),
description = currentModeLabel,
isTablet = isTablet,
onClick = { showSortModeSheet = true },
)
}
}
if (showSortModeSheet) {
ContinueWatchingSortModeDialog(
currentMode = sortMode,
onModeSelected = { mode ->
ContinueWatchingPreferencesRepository.setSortMode(mode)
showSortModeSheet = false
},
onDismiss = { showSortModeSheet = false },
)
}
}
}
@Composable
@ -250,3 +300,101 @@ private val ContinueWatchingSectionStyle.descriptionRes: StringResource
ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide_description
ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster_description
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ContinueWatchingSortModeDialog(
currentMode: ContinueWatchingSortMode,
onModeSelected: (ContinueWatchingSortMode) -> Unit,
onDismiss: () -> Unit,
) {
val options = listOf(
Triple(
ContinueWatchingSortMode.DEFAULT,
Res.string.settings_continue_watching_sort_mode_default,
Res.string.settings_continue_watching_sort_mode_default_desc,
),
Triple(
ContinueWatchingSortMode.STREAMING_STYLE,
Res.string.settings_continue_watching_sort_mode_streaming,
Res.string.settings_continue_watching_sort_mode_streaming_desc,
),
)
BasicAlertDialog(
onDismissRequest = onDismiss,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(Res.string.settings_continue_watching_sort_mode_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
options.forEach { (mode, titleRes, descriptionRes) ->
val isSelected = mode == currentMode
val containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onModeSelected(mode) },
shape = RoundedCornerShape(12.dp),
color = containerColor,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(titleRes),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(descriptionRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center,
) {
if (isSelected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
}
}
}
}

View file

@ -42,6 +42,8 @@ import nuvio.composeapp.generated.resources.layout_hide_unreleased
import nuvio.composeapp.generated.resources.layout_hide_unreleased_sub
import nuvio.composeapp.generated.resources.settings_homescreen_empty_message
import nuvio.composeapp.generated.resources.settings_homescreen_empty_title
import nuvio.composeapp.generated.resources.settings_homescreen_hide_catalog_underline
import nuvio.composeapp.generated.resources.settings_homescreen_hide_catalog_underline_description
import nuvio.composeapp.generated.resources.settings_homescreen_keep_home_focused
import nuvio.composeapp.generated.resources.settings_homescreen_limit_reached
import nuvio.composeapp.generated.resources.settings_homescreen_no_sources_selected
@ -65,6 +67,7 @@ internal fun LazyListScope.homescreenSettingsContent(
isTablet: Boolean,
heroEnabled: Boolean,
hideUnreleasedContent: Boolean,
hideCatalogUnderline: Boolean,
items: List<HomeCatalogSettingsItem>,
) {
val selectedHeroSourceCount = items.count { it.heroSourceEnabled }
@ -98,6 +101,14 @@ internal fun LazyListScope.homescreenSettingsContent(
isTablet = isTablet,
onCheckedChange = HomeCatalogSettingsRepository::setHideUnreleasedContent,
)
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.settings_homescreen_hide_catalog_underline),
description = stringResource(Res.string.settings_homescreen_hide_catalog_underline_description),
checked = hideCatalogUnderline,
isTablet = isTablet,
onCheckedChange = HomeCatalogSettingsRepository::setHideCatalogUnderline,
)
}
}
}

View file

@ -7,6 +7,7 @@ internal enum class IntegrationLogo {
Tmdb,
Trakt,
MdbList,
IntroDb,
}
@Composable

View file

@ -0,0 +1,341 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.isIos
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
private const val TmdbUrl = "https://www.themoviedb.org"
private const val ImdbDatasetsUrl = "https://developer.imdb.com/non-commercial-datasets/"
private const val TraktUrl = "https://trakt.tv"
private const val MdbListUrl = "https://mdblist.com"
private const val IntroDbUrl = "https://introdb.app/"
private const val NuvioRepositoryUrl = "https://github.com/NuvioMedia/NuvioMobile"
private const val MpvKitUrl = "https://github.com/mpvkit/MPVKit"
private const val ApacheLicenseUrl = "https://www.apache.org/licenses/LICENSE-2.0"
private data class AttributionItem(
val titleRes: StringResource,
val bodyRes: StringResource,
val logo: IntegrationLogo?,
val link: String,
)
private data class LicenseItem(
val titleRes: StringResource,
val bodyRes: StringResource,
val licenseRes: StringResource,
val link: String,
)
@Composable
fun LicensesAttributionsSettingsScreen(
onBack: () -> Unit,
) {
NuvioScreen(
modifier = Modifier.fillMaxSize(),
) {
stickyHeader {
NuvioScreenHeader(
title = stringResource(Res.string.compose_settings_page_licenses_attributions),
onBack = onBack,
)
}
licensesAttributionsContent(isTablet = false)
}
}
internal fun LazyListScope.licensesAttributionsContent(
isTablet: Boolean,
) {
item {
LicensesAttributionsBody(isTablet = isTablet)
}
}
@Composable
private fun LicensesAttributionsBody(
isTablet: Boolean,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(if (isTablet) 28.dp else 24.dp),
) {
PlainSettingsStack(
title = stringResource(Res.string.settings_licenses_attributions_section_app),
isTablet = isTablet,
) {
LicenseRow(
item = appLicenseItem(),
isTablet = isTablet,
)
}
PlainSettingsStack(
title = stringResource(Res.string.settings_licenses_attributions_section_data),
isTablet = isTablet,
) {
val items = attributionItems()
items.forEachIndexed { index, item ->
AttributionRow(
item = item,
isTablet = isTablet,
)
if (index != items.lastIndex) {
PlainStackDivider()
}
}
}
PlainSettingsStack(
title = stringResource(Res.string.settings_licenses_attributions_section_playback),
isTablet = isTablet,
) {
LicenseRow(
item = platformLicenseItem(),
isTablet = isTablet,
)
}
}
}
@Composable
private fun PlainSettingsStack(
title: String,
isTablet: Boolean,
content: @Composable () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(if (isTablet) 12.dp else 10.dp))
Column(
modifier = Modifier.fillMaxWidth(),
) {
content()
}
}
}
@Composable
private fun AttributionRow(
item: AttributionItem,
isTablet: Boolean,
) {
val uriHandler = LocalUriHandler.current
val title = stringResource(item.titleRes)
LinkedPlainRow(
title = title,
body = stringResource(item.bodyRes),
link = item.link,
isTablet = isTablet,
leading = item.logo?.let { logo ->
{
IntegrationLogoImage(
painter = integrationLogoPainter(logo),
contentDescription = title,
isTablet = isTablet,
)
}
},
onOpen = { uriHandler.openUri(item.link) },
)
}
@Composable
private fun LicenseRow(
item: LicenseItem,
isTablet: Boolean,
) {
val uriHandler = LocalUriHandler.current
val itemBody = stringResource(item.bodyRes)
val itemLicense = stringResource(item.licenseRes)
val body = buildString {
append(itemBody)
append("\n")
append(itemLicense)
}
LinkedPlainRow(
title = stringResource(item.titleRes),
body = body,
link = item.link,
isTablet = isTablet,
onOpen = { uriHandler.openUri(item.link) },
)
}
@Composable
private fun LinkedPlainRow(
title: String,
body: String,
link: String,
isTablet: Boolean,
leading: (@Composable () -> Unit)? = null,
onOpen: () -> Unit,
) {
val verticalPadding = if (isTablet) 18.dp else 16.dp
val horizontalPadding = if (isTablet) 4.dp else 0.dp
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpen)
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(if (isTablet) 18.dp else 14.dp),
) {
leading?.invoke()
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = link,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Icon(
imageVector = Icons.AutoMirrored.Rounded.OpenInNew,
contentDescription = null,
modifier = Modifier
.padding(top = 2.dp)
.size(if (isTablet) 22.dp else 20.dp)
.alpha(0.72f),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun IntegrationLogoImage(
painter: Painter,
contentDescription: String,
isTablet: Boolean,
) {
Image(
painter = painter,
contentDescription = contentDescription,
modifier = Modifier
.padding(top = 2.dp)
.size(if (isTablet) 46.dp else 40.dp),
contentScale = ContentScale.Fit,
)
}
@Composable
private fun PlainStackDivider() {
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.18f),
)
}
private fun attributionItems(): List<AttributionItem> = listOf(
AttributionItem(
titleRes = Res.string.settings_licenses_attributions_tmdb_title,
bodyRes = Res.string.settings_licenses_attributions_tmdb_body,
logo = IntegrationLogo.Tmdb,
link = TmdbUrl,
),
AttributionItem(
titleRes = Res.string.settings_licenses_attributions_trakt_title,
bodyRes = Res.string.settings_licenses_attributions_trakt_body,
logo = IntegrationLogo.Trakt,
link = TraktUrl,
),
AttributionItem(
titleRes = Res.string.settings_licenses_attributions_mdblist_title,
bodyRes = Res.string.settings_licenses_attributions_mdblist_body,
logo = IntegrationLogo.MdbList,
link = MdbListUrl,
),
AttributionItem(
titleRes = Res.string.settings_licenses_attributions_introdb_title,
bodyRes = Res.string.settings_licenses_attributions_introdb_body,
logo = IntegrationLogo.IntroDb,
link = IntroDbUrl,
),
AttributionItem(
titleRes = Res.string.settings_licenses_attributions_imdb_title,
bodyRes = Res.string.settings_licenses_attributions_imdb_body,
logo = null,
link = ImdbDatasetsUrl,
),
)
private fun appLicenseItem(): LicenseItem =
LicenseItem(
titleRes = Res.string.settings_licenses_attributions_nuvio_title,
bodyRes = Res.string.settings_licenses_attributions_nuvio_body,
licenseRes = Res.string.settings_licenses_attributions_nuvio_license,
link = NuvioRepositoryUrl,
)
private fun platformLicenseItem(): LicenseItem =
if (isIos) {
LicenseItem(
titleRes = Res.string.settings_licenses_attributions_mpvkit_title,
bodyRes = Res.string.settings_licenses_attributions_mpvkit_body,
licenseRes = Res.string.settings_licenses_attributions_mpvkit_license,
link = MpvKitUrl,
)
} else {
LicenseItem(
titleRes = Res.string.settings_licenses_attributions_exoplayer_title,
bodyRes = Res.string.settings_licenses_attributions_exoplayer_body,
licenseRes = Res.string.settings_licenses_attributions_exoplayer_license,
link = ApacheLicenseUrl,
)
}

View file

@ -78,6 +78,7 @@ fun HomescreenSettingsScreen(
isTablet = false,
heroEnabled = homescreenSettingsUiState.heroEnabled,
hideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
hideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline,
items = homescreenSettingsUiState.items,
)
}
@ -135,6 +136,7 @@ fun ContinueWatchingSettingsScreen(
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
sortMode = continueWatchingPreferencesUiState.sortMode,
)
}
}

View file

@ -16,6 +16,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_content_discov
import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching
import nuvio.composeapp.generated.resources.compose_settings_page_homescreen
import nuvio.composeapp.generated.resources.compose_settings_page_integrations
import nuvio.composeapp.generated.resources.compose_settings_page_licenses_attributions
import nuvio.composeapp.generated.resources.compose_settings_page_mdblist_ratings
import nuvio.composeapp.generated.resources.compose_settings_page_meta_screen
import nuvio.composeapp.generated.resources.compose_settings_page_notifications
@ -58,6 +59,11 @@ internal enum class SettingsPage(
category = SettingsCategory.About,
parentPage = Root,
),
LicensesAttributions(
titleRes = Res.string.compose_settings_page_licenses_attributions,
category = SettingsCategory.About,
parentPage = Root,
),
Playback(
titleRes = Res.string.compose_settings_page_playback,
category = SettingsCategory.General,

View file

@ -26,6 +26,7 @@ import nuvio.composeapp.generated.resources.compose_about_version_format
import nuvio.composeapp.generated.resources.compose_settings_page_account
import nuvio.composeapp.generated.resources.compose_settings_page_appearance
import nuvio.composeapp.generated.resources.compose_settings_page_integrations
import nuvio.composeapp.generated.resources.compose_settings_page_licenses_attributions
import nuvio.composeapp.generated.resources.compose_settings_page_notifications
import nuvio.composeapp.generated.resources.compose_settings_page_playback
import nuvio.composeapp.generated.resources.compose_settings_page_supporters_contributors
@ -48,6 +49,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_content_discov
import nuvio.composeapp.generated.resources.compose_settings_page_trakt
import nuvio.composeapp.generated.resources.settings_playback_subtitle
import nuvio.composeapp.generated.resources.about_supporters_contributors_subtitle
import nuvio.composeapp.generated.resources.about_licenses_attributions_subtitle
import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.settingsRootContent(
@ -59,6 +61,7 @@ internal fun LazyListScope.settingsRootContent(
onIntegrationsClick: () -> Unit,
onTraktClick: () -> Unit,
onSupportersContributorsClick: () -> Unit,
onLicensesAttributionsClick: () -> Unit,
onCheckForUpdatesClick: (() -> Unit)? = null,
onDownloadsClick: () -> Unit,
onAccountClick: () -> Unit,
@ -175,6 +178,14 @@ internal fun LazyListScope.settingsRootContent(
isTablet = isTablet,
onClick = onSupportersContributorsClick,
)
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.compose_settings_page_licenses_attributions),
description = stringResource(Res.string.about_licenses_attributions_subtitle),
icon = Icons.Rounded.Info,
isTablet = isTablet,
onClick = onLicensesAttributionsClick,
)
if (onCheckForUpdatesClick != null) {
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(

View file

@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@ -29,10 +30,19 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
@ -48,6 +58,7 @@ import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.details.MetaScreenSettingsUiState
import com.nuvio.app.core.ui.PosterCardStyleRepository
import com.nuvio.app.core.ui.PosterCardStyleUiState
import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.home.HomeCatalogSettingsItem
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.mdblist.MdbListSettings
@ -66,8 +77,14 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepositor
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.compose_settings_page_root
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
private val SettingsSearchRevealThreshold = 28.dp
private const val SettingsSearchRevealAnimationMillis = 240L
private const val SettingsSearchRevealHapticDelayMillis = 90L
@Composable
fun SettingsScreen(
modifier: Modifier = Modifier,
@ -80,6 +97,7 @@ fun SettingsScreen(
onDownloadsClick: () -> Unit = {},
onAccountClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {},
onLicensesAttributionsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {},
) {
@ -144,6 +162,7 @@ fun SettingsScreen(
HomeCatalogSettingsRepository.snapshot()
HomeCatalogSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
val metaScreenSettingsUiState by remember {
MetaScreenSettingsRepository.ensureLoaded()
MetaScreenSettingsRepository.uiState
@ -166,6 +185,14 @@ fun SettingsScreen(
HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons)
}
LaunchedEffect(Unit) {
CollectionRepository.initialize()
}
LaunchedEffect(collections) {
HomeCatalogSettingsRepository.syncCollections(collections)
}
var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) }
val page = remember(currentPage) { SettingsPage.valueOf(currentPage) }
val previousPage = page.previousPage()
@ -210,6 +237,7 @@ fun SettingsScreen(
traktSettingsUiState = traktSettingsUiState,
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
homescreenHideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline,
homescreenItems = homescreenSettingsUiState.items,
metaScreenSettingsUiState = metaScreenSettingsUiState,
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
@ -217,6 +245,7 @@ fun SettingsScreen(
onSwitchProfile = onSwitchProfile,
onDownloadsClick = onDownloadsClick,
onSupportersContributorsClick = onSupportersContributorsClick,
onLicensesAttributionsClick = onLicensesAttributionsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsClick,
)
@ -255,6 +284,7 @@ fun SettingsScreen(
traktSettingsUiState = traktSettingsUiState,
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
homescreenHideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline,
homescreenItems = homescreenSettingsUiState.items,
metaScreenSettingsUiState = metaScreenSettingsUiState,
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
@ -268,6 +298,7 @@ fun SettingsScreen(
onDownloadsClick = onDownloadsClick,
onAccountClick = onAccountClick,
onSupportersContributorsClick = onSupportersContributorsClick,
onLicensesAttributionsClick = onLicensesAttributionsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsClick,
)
@ -310,6 +341,7 @@ private fun MobileSettingsScreen(
traktSettingsUiState: TraktSettingsUiState,
homescreenHeroEnabled: Boolean,
homescreenHideUnreleasedContent: Boolean,
homescreenHideCatalogUnderline: Boolean,
homescreenItems: List<HomeCatalogSettingsItem>,
metaScreenSettingsUiState: MetaScreenSettingsUiState,
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
@ -323,12 +355,73 @@ private fun MobileSettingsScreen(
onDownloadsClick: () -> Unit = {},
onAccountClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {},
onLicensesAttributionsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {},
) {
val saveableStateHolder = rememberSaveableStateHolder()
saveableStateHolder.SaveableStateProvider(page.name) {
NuvioScreen {
var settingsSearchQuery by rememberSaveable { mutableStateOf("") }
var rootSearchVisible by rememberSaveable { mutableStateOf(false) }
var rootSearchRevealAnimating by rememberSaveable { mutableStateOf(false) }
val listState = rememberLazyListState()
val hapticFeedback = LocalHapticFeedback.current
val hapticScope = rememberCoroutineScope()
val rootSearchRevealConnection = rememberSettingsRootSearchRevealConnection(
page = page,
listState = listState,
query = settingsSearchQuery,
searchVisible = rootSearchVisible,
) {
rootSearchVisible = true
rootSearchRevealAnimating = true
hapticScope.launch {
delay(SettingsSearchRevealHapticDelayMillis)
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}
val searchEntries = settingsSearchEntries(
pluginsEnabled = AppFeaturePolicy.pluginsEnabled,
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
switchProfileAvailable = onSwitchProfile != null,
checkForUpdatesAvailable = onCheckForUpdatesClick != null,
)
fun openSearchTarget(target: SettingsSearchTarget) {
when (target) {
is SettingsSearchTarget.Page -> when (target.page) {
SettingsPage.Account -> onAccountClick()
SettingsPage.SupportersContributors -> onSupportersContributorsClick()
SettingsPage.LicensesAttributions -> onLicensesAttributionsClick()
SettingsPage.ContinueWatching -> onContinueWatchingClick()
SettingsPage.Addons -> onAddonsClick()
SettingsPage.Plugins -> {
if (AppFeaturePolicy.pluginsEnabled) {
onPluginsClick()
}
}
SettingsPage.Homescreen -> onHomescreenClick()
SettingsPage.MetaScreen -> onMetaScreenClick()
else -> onPageChange(target.page)
}
SettingsSearchTarget.Downloads -> onDownloadsClick()
SettingsSearchTarget.Collections -> onCollectionsClick()
SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke()
SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke()
}
}
LaunchedEffect(rootSearchRevealAnimating) {
if (rootSearchRevealAnimating) {
delay(SettingsSearchRevealAnimationMillis)
rootSearchRevealAnimating = false
}
}
NuvioScreen(
modifier = Modifier.nestedScroll(rootSearchRevealConnection),
listState = listState,
) {
stickyHeader {
val previousPage = page.previousPage()
NuvioScreenHeader(
@ -338,26 +431,43 @@ private fun MobileSettingsScreen(
}
when (page) {
SettingsPage.Root -> settingsRootContent(
isTablet = false,
onPlaybackClick = { onPageChange(SettingsPage.Playback) },
onAppearanceClick = { onPageChange(SettingsPage.Appearance) },
onNotificationsClick = { onPageChange(SettingsPage.Notifications) },
onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) },
onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = onSupportersContributorsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onDownloadsClick = onDownloadsClick,
onAccountClick = onAccountClick,
onSwitchProfileClick = onSwitchProfile,
)
SettingsPage.Root -> {
settingsSearchRootContent(
query = settingsSearchQuery,
entries = searchEntries,
isTablet = false,
showSearchField = rootSearchVisible,
animateSearchField = rootSearchRevealAnimating,
onQueryChange = { settingsSearchQuery = it },
onTargetClick = { openSearchTarget(it) },
)
if (settingsSearchQuery.isBlank()) {
settingsRootContent(
isTablet = false,
onPlaybackClick = { onPageChange(SettingsPage.Playback) },
onAppearanceClick = { onPageChange(SettingsPage.Appearance) },
onNotificationsClick = { onPageChange(SettingsPage.Notifications) },
onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) },
onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = onSupportersContributorsClick,
onLicensesAttributionsClick = onLicensesAttributionsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onDownloadsClick = onDownloadsClick,
onAccountClick = onAccountClick,
onSwitchProfileClick = onSwitchProfile,
)
}
}
SettingsPage.Account -> accountSettingsContent(
isTablet = false,
)
SettingsPage.SupportersContributors -> supportersContributorsContent(
isTablet = false,
)
SettingsPage.LicensesAttributions -> licensesAttributionsContent(
isTablet = false,
)
SettingsPage.Playback -> playbackSettingsContent(
isTablet = false,
showLoadingOverlay = showLoadingOverlay,
@ -402,6 +512,7 @@ private fun MobileSettingsScreen(
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
sortMode = continueWatchingPreferencesUiState.sortMode,
)
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
isTablet = false,
@ -422,6 +533,7 @@ private fun MobileSettingsScreen(
isTablet = false,
heroEnabled = homescreenHeroEnabled,
hideUnreleasedContent = homescreenHideUnreleasedContent,
hideCatalogUnderline = homescreenHideCatalogUnderline,
items = homescreenItems,
)
SettingsPage.MetaScreen -> metaScreenSettingsContent(
@ -453,6 +565,48 @@ private fun MobileSettingsScreen(
}
}
@Composable
private fun rememberSettingsRootSearchRevealConnection(
page: SettingsPage,
listState: LazyListState,
query: String,
searchVisible: Boolean,
onReveal: () -> Unit,
): NestedScrollConnection {
val revealThresholdPx = with(LocalDensity.current) { SettingsSearchRevealThreshold.toPx() }
val currentOnReveal by rememberUpdatedState(onReveal)
var pullDistancePx by remember(page) { mutableStateOf(0f) }
var revealTriggered by remember(page) { mutableStateOf(false) }
return remember(page, listState, query, searchVisible, revealThresholdPx) {
object : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
val isRootAtTop = page == SettingsPage.Root &&
listState.firstVisibleItemIndex == 0 &&
listState.firstVisibleItemScrollOffset == 0
val canRevealSearch = isRootAtTop && !searchVisible && !revealTriggered && query.isBlank()
if (canRevealSearch && available.y > 0f) {
pullDistancePx += available.y
if (pullDistancePx >= revealThresholdPx) {
pullDistancePx = 0f
revealTriggered = true
currentOnReveal()
}
} else if (!isRootAtTop || available.y < 0f) {
pullDistancePx = 0f
}
return Offset.Zero
}
}
}
}
@Composable
private fun TabletSettingsScreen(
page: SettingsPage,
@ -488,6 +642,7 @@ private fun TabletSettingsScreen(
traktSettingsUiState: TraktSettingsUiState,
homescreenHeroEnabled: Boolean,
homescreenHideUnreleasedContent: Boolean,
homescreenHideCatalogUnderline: Boolean,
homescreenItems: List<HomeCatalogSettingsItem>,
metaScreenSettingsUiState: MetaScreenSettingsUiState,
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
@ -495,6 +650,7 @@ private fun TabletSettingsScreen(
onSwitchProfile: (() -> Unit)? = null,
onDownloadsClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {},
onLicensesAttributionsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {},
) {
@ -559,11 +715,54 @@ private fun TabletSettingsScreen(
}
saveableStateHolder.SaveableStateProvider(page.name) {
var settingsSearchQuery by rememberSaveable { mutableStateOf("") }
var rootSearchVisible by rememberSaveable { mutableStateOf(false) }
var rootSearchRevealAnimating by rememberSaveable { mutableStateOf(false) }
val hapticFeedback = LocalHapticFeedback.current
val hapticScope = rememberCoroutineScope()
val searchEntries = settingsSearchEntries(
pluginsEnabled = AppFeaturePolicy.pluginsEnabled,
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
switchProfileAvailable = onSwitchProfile != null,
checkForUpdatesAvailable = onCheckForUpdatesClick != null,
)
fun openSearchTarget(target: SettingsSearchTarget) {
when (target) {
is SettingsSearchTarget.Page -> openInlinePage(target.page)
SettingsSearchTarget.Downloads -> onDownloadsClick()
SettingsSearchTarget.Collections -> onCollectionsClick()
SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke()
SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke()
}
}
val listState = rememberLazyListState()
val bottomOverlayPadding = LocalNuvioBottomNavigationOverlayPadding.current
val rootSearchRevealConnection = rememberSettingsRootSearchRevealConnection(
page = page,
listState = listState,
query = settingsSearchQuery,
searchVisible = rootSearchVisible,
) {
rootSearchVisible = true
rootSearchRevealAnimating = true
hapticScope.launch {
delay(SettingsSearchRevealHapticDelayMillis)
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}
LaunchedEffect(rootSearchRevealAnimating) {
if (rootSearchRevealAnimating) {
delay(SettingsSearchRevealAnimationMillis)
rootSearchRevealAnimating = false
}
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.nestedScroll(rootSearchRevealConnection),
contentPadding = PaddingValues(
start = 40.dp,
top = topOffset,
@ -576,7 +775,11 @@ private fun TabletSettingsScreen(
val previousPage = page.previousPage()
TabletPageHeader(
title = if (page == SettingsPage.Root) {
stringResource(activeCategory.labelRes)
if (settingsSearchQuery.isBlank()) {
stringResource(activeCategory.labelRes)
} else {
stringResource(Res.string.compose_settings_page_root)
}
} else {
stringResource(page.titleRes)
},
@ -585,29 +788,46 @@ private fun TabletSettingsScreen(
)
}
when (page) {
SettingsPage.Root -> settingsRootContent(
isTablet = true,
onPlaybackClick = { openInlinePage(SettingsPage.Playback) },
onAppearanceClick = { openInlinePage(SettingsPage.Appearance) },
onNotificationsClick = { openInlinePage(SettingsPage.Notifications) },
onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) },
onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
onCheckForUpdatesClick = onCheckForUpdatesClick,
onDownloadsClick = onDownloadsClick,
onAccountClick = { openInlinePage(SettingsPage.Account) },
onSwitchProfileClick = onSwitchProfile,
showAccountSection = activeCategory == SettingsCategory.Account,
showGeneralSection = activeCategory == SettingsCategory.General,
showAboutSection = activeCategory == SettingsCategory.About,
)
SettingsPage.Root -> {
settingsSearchRootContent(
query = settingsSearchQuery,
entries = searchEntries,
isTablet = true,
showSearchField = rootSearchVisible,
animateSearchField = rootSearchRevealAnimating,
onQueryChange = { settingsSearchQuery = it },
onTargetClick = { openSearchTarget(it) },
)
if (settingsSearchQuery.isBlank()) {
settingsRootContent(
isTablet = true,
onPlaybackClick = { openInlinePage(SettingsPage.Playback) },
onAppearanceClick = { openInlinePage(SettingsPage.Appearance) },
onNotificationsClick = { openInlinePage(SettingsPage.Notifications) },
onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) },
onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
onLicensesAttributionsClick = { openInlinePage(SettingsPage.LicensesAttributions) },
onCheckForUpdatesClick = onCheckForUpdatesClick,
onDownloadsClick = onDownloadsClick,
onAccountClick = { openInlinePage(SettingsPage.Account) },
onSwitchProfileClick = onSwitchProfile,
showAccountSection = activeCategory == SettingsCategory.Account,
showGeneralSection = activeCategory == SettingsCategory.General,
showAboutSection = activeCategory == SettingsCategory.About,
)
}
}
SettingsPage.Account -> accountSettingsContent(
isTablet = true,
)
SettingsPage.SupportersContributors -> supportersContributorsContent(
isTablet = true,
)
SettingsPage.LicensesAttributions -> licensesAttributionsContent(
isTablet = true,
)
SettingsPage.Playback -> playbackSettingsContent(
isTablet = true,
showLoadingOverlay = showLoadingOverlay,
@ -652,6 +872,7 @@ private fun TabletSettingsScreen(
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
sortMode = continueWatchingPreferencesUiState.sortMode,
)
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
isTablet = true,
@ -672,6 +893,7 @@ private fun TabletSettingsScreen(
isTablet = true,
heroEnabled = homescreenHeroEnabled,
hideUnreleasedContent = homescreenHideUnreleasedContent,
hideCatalogUnderline = homescreenHideCatalogUnderline,
items = homescreenItems,
)
SettingsPage.MetaScreen -> metaScreenSettingsContent(

View file

@ -22,8 +22,20 @@ internal expect fun epochMs(): Long
object StreamLinkCacheRepository {
private val json = Json { ignoreUnknownKeys = true }
fun contentKey(type: String, videoId: String): String =
"${type.lowercase()}|$videoId"
fun contentKey(
type: String,
videoId: String,
parentMetaId: String? = null,
season: Int? = null,
episode: Int? = null,
): String {
val normalizedType = type.lowercase()
return if (!parentMetaId.isNullOrBlank() && season != null && episode != null) {
"$normalizedType|${parentMetaId.trim()}|s$season|e$episode|$videoId"
} else {
"$normalizedType|$videoId"
}
}
fun save(
contentKey: String,

View file

@ -66,6 +66,7 @@ enum class StreamsEmptyStateReason {
}
data class StreamsUiState(
val requestToken: String? = null,
val groups: List<AddonStreamGroup> = emptyList(),
val activeAddonIds: Set<String> = emptySet(),
val selectedFilter: String? = null,

View file

@ -36,6 +36,15 @@ object StreamsRepository {
private var activeJob: Job? = null
private var activeRequestKey: String? = null
fun requestToken(
type: String,
videoId: String,
season: Int? = null,
episode: Int? = null,
manualSelection: Boolean = false,
): String =
"$type::$videoId::$season::$episode::$manualSelection"
fun load(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) {
load(
type = type,
@ -65,7 +74,14 @@ object StreamsRepository {
} else {
PluginsUiState(pluginsEnabled = false)
}
val requestKey = "$type::$videoId::$season::$episode::$manualSelection::pluginsGrouped=${pluginUiState.groupStreamsByRepository}"
val requestToken = requestToken(
type = type,
videoId = videoId,
season = season,
episode = episode,
manualSelection = manualSelection,
)
val requestKey = "$requestToken::pluginsGrouped=${pluginUiState.groupStreamsByRepository}"
val currentState = _uiState.value
if (
!forceRefresh &&
@ -78,7 +94,7 @@ object StreamsRepository {
activeRequestKey = requestKey
activeJob?.cancel()
_uiState.value = StreamsUiState()
_uiState.value = StreamsUiState(requestToken = requestToken)
PlayerSettingsRepository.ensureLoaded()
val playerSettings = PlayerSettingsRepository.uiState.value
@ -90,6 +106,7 @@ object StreamsRepository {
if (isDirectAutoPlayFlow) {
_uiState.value = StreamsUiState(
requestToken = requestToken,
isDirectAutoPlayFlow = true,
showDirectAutoPlayOverlay = true,
)
@ -105,6 +122,7 @@ object StreamsRepository {
isLoading = false,
)
_uiState.value = StreamsUiState(
requestToken = requestToken,
groups = listOf(group),
activeAddonIds = setOf("embedded"),
isAnyLoading = false,
@ -125,6 +143,7 @@ object StreamsRepository {
if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
_uiState.value = StreamsUiState(
requestToken = requestToken,
isAnyLoading = false,
emptyStateReason = StreamsEmptyStateReason.NoAddonsInstalled,
)
@ -151,8 +170,9 @@ object StreamsRepository {
log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" }
if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
_uiState.value = StreamsUiState(
requestToken = requestToken,
isAnyLoading = false,
emptyStateReason = StreamsEmptyStateReason.NoCompatibleAddons,
)
@ -176,6 +196,7 @@ object StreamsRepository {
)
}
_uiState.value = StreamsUiState(
requestToken = requestToken,
groups = initialGroups,
activeAddonIds = initialGroups.map { it.addonId }.toSet(),
isAnyLoading = true,

View file

@ -160,7 +160,7 @@ fun StreamsScreen(
}
}
LaunchedEffect(type, videoId, manualSelection) {
LaunchedEffect(type, videoId, seasonNumber, episodeNumber, manualSelection) {
StreamsRepository.load(
type = type,
videoId = videoId,

View file

@ -15,8 +15,7 @@ private const val COMMENTS_SORT = "likes"
private const val COMMENTS_LIMIT = 100
private const val COMMENTS_CACHE_TTL_MS = 10 * 60_000L
private val INLINE_SPOILER_REGEX = Regex(
"\\[spoiler\\].*?\\[/spoiler\\]",
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL),
"(?is)\\[spoiler\\].*?\\[/spoiler\\]"
)
private val INLINE_SPOILER_TAG_REGEX = Regex("\\[/?spoiler\\]", RegexOption.IGNORE_CASE)

View file

@ -51,6 +51,7 @@ object TraktEpisodeMappingService {
videoId: String?,
season: Int?,
episode: Int?,
episodeTitle: String? = null,
): EpisodeMappingEntry? {
val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null
cacheMutex.withLock {
@ -77,7 +78,7 @@ object TraktEpisodeMappingService {
requestedSeason = requestedSeason,
requestedEpisode = requestedEpisode,
requestedVideoId = videoId,
requestedTitle = null,
requestedTitle = episodeTitle,
addonEpisodes = addonEpisodes,
traktEpisodes = traktEpisodes,
) ?: return null
@ -176,18 +177,18 @@ object TraktEpisodeMappingService {
// ── Season structure comparison ───────────────────────────────────────
private fun hasSameSeasonStructure(
internal fun hasSameSeasonStructure(
addonEpisodes: List<EpisodeMappingEntry>,
traktEpisodes: List<EpisodeMappingEntry>,
): Boolean {
val addonSeasons = addonEpisodes.mapTo(mutableSetOf()) { it.season }
val traktSeasons = traktEpisodes.mapTo(mutableSetOf()) { it.season }
return addonSeasons == traktSeasons
val addonPerSeason = addonEpisodes.groupBy { it.season }.mapValues { it.value.size }
val traktPerSeason = traktEpisodes.groupBy { it.season }.mapValues { it.value.size }
return addonPerSeason == traktPerSeason
}
// ── Forward mapping: addon → Trakt ──────────────────────────────────
private fun remapEpisodeByTitleOrIndex(
internal fun remapEpisodeByTitleOrIndex(
requestedSeason: Int,
requestedEpisode: Int,
requestedVideoId: String?,
@ -195,63 +196,72 @@ object TraktEpisodeMappingService {
addonEpisodes: List<EpisodeMappingEntry>,
traktEpisodes: List<EpisodeMappingEntry>,
): EpisodeMappingEntry? {
// Find the addon episode entry
val addonEntry = addonEpisodes.firstOrNull {
it.season == requestedSeason && it.episode == requestedEpisode
} ?: addonEpisodes.firstOrNull {
!requestedVideoId.isNullOrBlank() && it.videoId == requestedVideoId
} ?: return null
// Try title match first
val titleToMatch = addonEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle
if (!titleToMatch.isNullOrBlank()) {
val titleMatch = traktEpisodes.firstOrNull { target ->
!target.title.isNullOrBlank() &&
normalizeTitle(target.title) == normalizeTitle(titleToMatch)
}
if (titleMatch != null) {
return titleMatch
}
}
// Fallback: global index mapping
val addonIndex = addonEpisodes.indexOf(addonEntry)
if (addonIndex < 0 || addonIndex >= traktEpisodes.size) return null
return traktEpisodes[addonIndex]
return remapEpisodeBetweenLists(
requestedSeason = requestedSeason,
requestedEpisode = requestedEpisode,
requestedVideoId = requestedVideoId,
requestedTitle = requestedTitle,
sourceEpisodes = addonEpisodes,
targetEpisodes = traktEpisodes,
)
}
// ── Reverse mapping: Trakt → addon ──────────────────────────────────
private fun reverseRemapEpisodeByTitleOrIndex(
internal fun reverseRemapEpisodeByTitleOrIndex(
requestedSeason: Int,
requestedEpisode: Int,
requestedTitle: String?,
addonEpisodes: List<EpisodeMappingEntry>,
traktEpisodes: List<EpisodeMappingEntry>,
): EpisodeMappingEntry? {
// Find the Trakt episode entry
val traktEntry = traktEpisodes.firstOrNull {
it.season == requestedSeason && it.episode == requestedEpisode
} ?: return null
return remapEpisodeBetweenLists(
requestedSeason = requestedSeason,
requestedEpisode = requestedEpisode,
requestedVideoId = null,
requestedTitle = requestedTitle,
sourceEpisodes = traktEpisodes,
targetEpisodes = addonEpisodes,
)
}
// Try title match first
val titleToMatch = traktEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle
if (!titleToMatch.isNullOrBlank()) {
val titleMatch = addonEpisodes.firstOrNull { target ->
!target.title.isNullOrBlank() &&
normalizeTitle(target.title) == normalizeTitle(titleToMatch)
private fun remapEpisodeBetweenLists(
requestedSeason: Int,
requestedEpisode: Int,
requestedVideoId: String?,
requestedTitle: String?,
sourceEpisodes: List<EpisodeMappingEntry>,
targetEpisodes: List<EpisodeMappingEntry>,
): EpisodeMappingEntry? {
if (sourceEpisodes.isEmpty() || targetEpisodes.isEmpty()) return null
val orderedSourceEpisodes = sourceEpisodes
.sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode))
val orderedTargetEpisodes = targetEpisodes
.sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode))
val currentSourceEpisode = requestedVideoId
?.takeIf { it.isNotBlank() }
?.let { videoId -> orderedSourceEpisodes.firstOrNull { it.videoId == videoId } }
?: orderedSourceEpisodes.firstOrNull {
it.season == requestedSeason && it.episode == requestedEpisode
}
if (titleMatch != null) {
return titleMatch
?: return null
val normalizedTitle = normalizeEpisodeTitle(requestedTitle ?: currentSourceEpisode.title)
if (isUsefulEpisodeTitle(normalizedTitle)) {
val titleMatches = orderedTargetEpisodes.filter {
normalizeEpisodeTitle(it.title) == normalizedTitle
}
if (titleMatches.size == 1) {
return titleMatches.first()
}
}
// Fallback: global index mapping
val traktIndex = traktEpisodes.indexOf(traktEntry)
if (traktIndex < 0 || traktIndex >= addonEpisodes.size) return null
val sourceIndex = orderedSourceEpisodes.indexOf(currentSourceEpisode)
if (sourceIndex !in orderedTargetEpisodes.indices) return null
return addonEpisodes[traktIndex]
return orderedTargetEpisodes[sourceIndex]
}
// ── Addon episodes fetching (with dedup) ───────────────────────────
@ -396,7 +406,7 @@ object TraktEpisodeMappingService {
return when {
!contentIds.imdb.isNullOrBlank() -> contentIds.imdb
contentIds.trakt != null -> contentIds.trakt.toString()
contentIds.tmdb != null -> contentIds.tmdb.toString()
!contentIds.slug.isNullOrBlank() -> contentIds.slug
else -> null
}
}
@ -405,13 +415,13 @@ object TraktEpisodeMappingService {
return when {
!videoIds.imdb.isNullOrBlank() -> videoIds.imdb
videoIds.trakt != null -> videoIds.trakt.toString()
videoIds.tmdb != null -> videoIds.tmdb.toString()
!videoIds.slug.isNullOrBlank() -> videoIds.slug
else -> null
}
}
private fun TraktExternalIds.hasAnyId(): Boolean =
!imdb.isNullOrBlank() || trakt != null || tmdb != null
!imdb.isNullOrBlank() || trakt != null || !slug.isNullOrBlank()
private fun cacheKey(
contentId: String?,
@ -461,9 +471,22 @@ object TraktEpisodeMappingService {
.toList()
}
private fun normalizeTitle(title: String?): String =
title.orEmpty().trim().lowercase()
.replace(Regex("[^a-z0-9]"), "")
private fun normalizeEpisodeTitle(title: String?): String {
return title
.orEmpty()
.lowercase()
.replace(Regex("[^a-z0-9]+"), " ")
.trim()
.replace(Regex("\\s+"), " ")
}
private fun isUsefulEpisodeTitle(normalizedTitle: String): Boolean {
if (normalizedTitle.isBlank()) return false
if (normalizedTitle.matches(Regex("episode \\d+"))) return false
if (normalizedTitle.matches(Regex("ep \\d+"))) return false
if (normalizedTitle.matches(Regex("e \\d+"))) return false
return true
}
}
// ── Data classes ────────────────────────────────────────────────────────

View file

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

View file

@ -475,27 +475,27 @@ object TraktProgressRepository {
var resolvedEpisode = entry.episodeNumber
val episode = if (resolvedSeason != null && resolvedEpisode != null) {
// Try direct match first
val directMatch = meta.videos.firstOrNull { video ->
video.season == resolvedSeason && video.episode == resolvedEpisode
}
if (directMatch != null) {
directMatch
} else {
// Fallback: reverse-remap from Trakt numbering to addon numbering
val addonSeasons = meta.videos.mapTo(mutableSetOf()) { it.season }
if (resolvedSeason == 1 && addonSeasons.size > 1 && resolvedEpisode!! > 0) {
val sorted = meta.videos
.filter { it.season != null && it.episode != null }
.sortedWith(compareBy({ it.season }, { it.episode }))
val globalIndex = resolvedEpisode!! - 1
if (globalIndex in sorted.indices) {
val remapped = sorted[globalIndex]
resolvedSeason = remapped.season
resolvedEpisode = remapped.episode
remapped
} else null
} else null
val remapped = resolveAddonEpisodeProgress(
contentId = entry.parentMetaId,
season = resolvedSeason,
episode = resolvedEpisode,
episodeTitle = entry.episodeTitle,
)
if (remapped != null) {
resolvedSeason = remapped.season
resolvedEpisode = remapped.episode
meta.videos.firstOrNull { video ->
video.season == remapped.season && video.episode == remapped.episode
}
} else {
null
}
}
} else {
null
@ -540,7 +540,7 @@ object TraktProgressRepository {
).normalizedCompletion()
}
private fun mapPlaybackEpisode(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? {
private suspend fun mapPlaybackEpisode(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? {
val show = item.show ?: return null
val episode = item.episode ?: return null
val season = episode.season ?: return null
@ -551,6 +551,14 @@ object TraktProgressRepository {
val progressPercent = normalizeTraktProgressPercent(item.progress) ?: return null
if (progressPercent <= 0f) return null
val resolvedEpisode = resolveAddonEpisodeProgress(
contentId = parentMetaId,
season = season,
episode = number,
episodeTitle = episode.title,
)
val resolvedSeason = resolvedEpisode?.season ?: season
val resolvedNumber = resolvedEpisode?.episode ?: number
return WatchProgressEntry(
contentType = "series",
@ -558,14 +566,14 @@ object TraktProgressRepository {
parentMetaType = "series",
videoId = buildPlaybackVideoId(
parentMetaId = parentMetaId,
seasonNumber = season,
episodeNumber = number,
seasonNumber = resolvedSeason,
episodeNumber = resolvedNumber,
fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" },
),
title = show.title ?: parentMetaId,
seasonNumber = season,
episodeNumber = number,
episodeTitle = episode.title,
seasonNumber = resolvedSeason,
episodeNumber = resolvedNumber,
episodeTitle = resolvedEpisode?.title ?: episode.title,
lastPositionMs = 0L,
durationMs = 0L,
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
@ -575,7 +583,7 @@ object TraktProgressRepository {
).normalizedCompletion()
}
private fun mapHistoryEpisode(item: TraktHistoryEpisodeItem, fallbackIndex: Int): WatchProgressEntry? {
private suspend fun mapHistoryEpisode(item: TraktHistoryEpisodeItem, fallbackIndex: Int): WatchProgressEntry? {
val show = item.show ?: return null
val episode = item.episode ?: return null
val season = episode.season ?: return null
@ -583,6 +591,14 @@ object TraktProgressRepository {
val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title)
if (parentMetaId.isBlank()) return null
val resolvedEpisode = resolveAddonEpisodeProgress(
contentId = parentMetaId,
season = season,
episode = number,
episodeTitle = episode.title,
)
val resolvedSeason = resolvedEpisode?.season ?: season
val resolvedNumber = resolvedEpisode?.episode ?: number
return WatchProgressEntry(
contentType = "series",
@ -590,14 +606,14 @@ object TraktProgressRepository {
parentMetaType = "series",
videoId = buildPlaybackVideoId(
parentMetaId = parentMetaId,
seasonNumber = season,
episodeNumber = number,
seasonNumber = resolvedSeason,
episodeNumber = resolvedNumber,
fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" },
),
title = show.title ?: parentMetaId,
seasonNumber = season,
episodeNumber = number,
episodeTitle = episode.title,
seasonNumber = resolvedSeason,
episodeNumber = resolvedNumber,
episodeTitle = resolvedEpisode?.title ?: episode.title,
lastPositionMs = 1L,
durationMs = 1L,
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
@ -627,7 +643,7 @@ object TraktProgressRepository {
)
}
private fun mapWatchedShowSeed(
private suspend fun mapWatchedShowSeed(
item: TraktWatchedShowItem,
useFurthestEpisode: Boolean,
): WatchProgressEntry? {
@ -670,6 +686,14 @@ object TraktProgressRepository {
)
},
) ?: return null
val resolvedEpisode = resolveAddonEpisodeProgress(
contentId = parentMetaId,
season = completedEpisode.season,
episode = completedEpisode.episode,
episodeTitle = null,
)
val resolvedSeason = resolvedEpisode?.season ?: completedEpisode.season
val resolvedNumber = resolvedEpisode?.episode ?: completedEpisode.episode
return WatchProgressEntry(
contentType = "series",
@ -677,13 +701,14 @@ object TraktProgressRepository {
parentMetaType = "series",
videoId = buildPlaybackVideoId(
parentMetaId = parentMetaId,
seasonNumber = completedEpisode.season,
episodeNumber = completedEpisode.episode,
seasonNumber = resolvedSeason,
episodeNumber = resolvedNumber,
fallbackVideoId = null,
),
title = show.title ?: parentMetaId,
seasonNumber = completedEpisode.season,
episodeNumber = completedEpisode.episode,
seasonNumber = resolvedSeason,
episodeNumber = resolvedNumber,
episodeTitle = resolvedEpisode?.title,
lastPositionMs = 1L,
durationMs = 1L,
lastUpdatedEpochMs = completedEpisode.watchedAt,
@ -710,6 +735,26 @@ object TraktProgressRepository {
?.let { return it }
return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L)
}
private suspend fun resolveAddonEpisodeProgress(
contentId: String,
season: Int,
episode: Int,
episodeTitle: String?,
): EpisodeMappingEntry? {
return runCatching {
TraktEpisodeMappingService.resolveAddonEpisodeMapping(
contentId = contentId,
contentType = "series",
season = season,
episode = episode,
episodeTitle = episodeTitle,
)
}.onFailure { error ->
if (error is CancellationException) throw error
log.w { "resolveAddonEpisodeProgress failed for $contentId s=$season e=$episode: ${error.message}" }
}.getOrNull()
}
}
@Serializable

View file

@ -63,9 +63,10 @@ internal object TraktScrobbleRepository {
sendScrobble(action = "stop", item = item, progressPercent = progressPercent)
}
fun buildItem(
suspend fun buildItem(
contentType: String,
parentMetaId: String,
videoId: String?,
title: String?,
seasonNumber: Int?,
episodeNumber: Int?,
@ -81,12 +82,20 @@ internal object TraktScrobbleRepository {
seasonNumber != null &&
episodeNumber != null
) {
val mappedEpisode = TraktEpisodeMappingService.resolveEpisodeMapping(
contentId = parentMetaId,
contentType = contentType,
videoId = videoId,
season = seasonNumber,
episode = episodeNumber,
episodeTitle = episodeTitle,
)
TraktScrobbleItem.Episode(
showTitle = title,
showYear = parsedYear,
showIds = ids,
season = seasonNumber,
number = episodeNumber,
season = mappedEpisode?.season ?: seasonNumber,
number = mappedEpisode?.episode ?: episodeNumber,
episodeTitle = episodeTitle,
)
} else {
@ -247,6 +256,9 @@ internal object TraktScrobbleRepository {
val isSameAction = last.action == action
val isSameItem = last.itemKey == itemKey
val isNearProgress = abs(last.progress - progress) <= progressWindow
if (action == "stop" && last.action == "start" && isSameItem) {
return false
}
return isSameWindow && isSameAction && isSameItem && isNearProgress
}

View file

@ -53,11 +53,14 @@ fun nextReleasedEpisodeAfter(
// Fallback: if the seed wasn't found by season+episode (anime with absolute
// numbering on Trakt vs multi-season on addon), try global index matching.
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.seasonNumber }
val mainEpisodes = sortedEpisodes.filter { episode -> normalizeSeasonNumber(episode.seasonNumber) > 0 }
val addonSeasons = mainEpisodes.mapTo(mutableSetOf()) { episode ->
normalizeSeasonNumber(episode.seasonNumber)
}
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
val globalIndex = episodeNumber - 1
if (globalIndex in sortedEpisodes.indices) {
watchedIndex = globalIndex
if (globalIndex in mainEpisodes.indices) {
watchedIndex = sortedEpisodes.indexOf(mainEpisodes[globalIndex])
}
}
}

View file

@ -22,6 +22,8 @@ private data class StoredContinueWatchingPreferences(
val blurNextUp: Boolean = false,
val dismissedNextUpKeys: Set<String> = emptySet(),
val showResumePromptOnLaunch: Boolean = true,
@SerialName("sort_mode")
val sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
)
object ContinueWatchingPreferencesRepository {
@ -97,6 +99,7 @@ object ContinueWatchingPreferencesRepository {
blurNextUp = stored.blurNextUp,
dismissedNextUpKeys = stored.dismissedNextUpKeys,
showResumePromptOnLaunch = stored.showResumePromptOnLaunch,
sortMode = stored.sortMode,
)
} else {
ContinueWatchingPreferencesUiState()
@ -155,6 +158,13 @@ object ContinueWatchingPreferencesRepository {
persist()
}
fun setSortMode(mode: ContinueWatchingSortMode) {
ensureLoaded()
if (_uiState.value.sortMode == mode) return
_uiState.value = _uiState.value.copy(sortMode = mode)
persist()
}
fun removeDismissedNextUpKeysForContent(contentId: String) {
ensureLoaded()
val normalizedContentId = contentId.trim()
@ -178,6 +188,7 @@ object ContinueWatchingPreferencesRepository {
blurNextUp = _uiState.value.blurNextUp,
dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys,
showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch,
sortMode = _uiState.value.sortMode,
),
),
)

View file

@ -17,6 +17,12 @@ enum class ContinueWatchingSectionStyle {
Poster,
}
@Serializable
enum class ContinueWatchingSortMode {
DEFAULT,
STREAMING_STYLE,
}
@Serializable
data class WatchProgressEntry(
val contentType: String,
@ -175,6 +181,7 @@ data class ContinueWatchingPreferencesUiState(
val blurNextUp: Boolean = false,
val dismissedNextUpKeys: Set<String> = emptySet(),
val showResumePromptOnLaunch: Boolean = true,
val sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
)
internal fun nextUpDismissKey(

View file

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

View file

@ -88,4 +88,31 @@ class SeriesPlaybackResolverTest {
assertEquals("Up Next • S1E3", action.label)
assertEquals("show:1:3", action.videoId)
}
@Test
fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() {
val meta = MetaDetails(
id = "show",
type = "series",
name = "Show",
videos = listOf(
MetaVideo(id = "sp1", title = "Special 1", season = 0, episode = 1, released = "2026-01-01"),
MetaVideo(id = "s1e1", title = "Episode 1", season = 1, episode = 1, released = "2026-01-08"),
MetaVideo(id = "s1e2", title = "Episode 2", season = 1, episode = 2, released = "2026-01-15"),
MetaVideo(id = "s2e1", title = "Episode 3", season = 2, episode = 1, released = "2026-01-22"),
MetaVideo(id = "s2e2", title = "Episode 4", season = 2, episode = 2, released = "2026-01-29"),
),
)
val nextEpisode = meta.nextReleasedEpisodeAfter(
seasonNumber = 1,
episodeNumber = 3,
todayIsoDate = "2026-02-01",
)
assertNotNull(nextEpisode)
assertEquals(2, nextEpisode.season)
assertEquals(2, nextEpisode.episode)
assertEquals("s2e2", nextEpisode.id)
}
}

View file

@ -0,0 +1,39 @@
package com.nuvio.app.features.streams
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
class StreamLinkCacheRepositoryTest {
@Test
fun `movie cache key keeps legacy type and video id shape`() {
val key = StreamLinkCacheRepository.contentKey(
type = "movie",
videoId = "tt123",
)
assertEquals("movie|tt123", key)
}
@Test
fun `episode cache key is scoped to parent show and episode`() {
val firstEpisode = StreamLinkCacheRepository.contentKey(
type = "series",
videoId = "video-id",
parentMetaId = "tt999",
season = 1,
episode = 1,
)
val secondEpisode = StreamLinkCacheRepository.contentKey(
type = "series",
videoId = "video-id",
parentMetaId = "tt999",
season = 1,
episode = 2,
)
assertNotEquals(firstEpisode, secondEpisode)
assertEquals("series|tt999|s1|e1|video-id", firstEpisode)
}
}

View file

@ -0,0 +1,215 @@
package com.nuvio.app.features.trakt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class TraktEpisodeMappingServiceTest {
@Test
fun `same structure compares per-season episode counts`() {
val addon = listOf(
episode(1, 1),
episode(1, 2),
episode(2, 1),
)
val sameSeasonsDifferentCounts = listOf(
episode(1, 1),
episode(2, 1),
episode(2, 2),
)
val sameCounts = listOf(
episode(1, 1),
episode(1, 2),
episode(2, 1),
)
assertFalse(TraktEpisodeMappingService.hasSameSeasonStructure(addon, sameSeasonsDifferentCounts))
assertTrue(TraktEpisodeMappingService.hasSameSeasonStructure(addon, sameCounts))
}
@Test
fun `forward mapping uses global sorted index for anime numbering`() {
val addon = listOf(
episode(1, 1, videoId = "show:1:1"),
episode(1, 2, videoId = "show:1:2"),
episode(2, 1, videoId = "show:2:1"),
episode(2, 2, videoId = "show:2:2"),
)
val trakt = listOf(
episode(1, 1),
episode(1, 2),
episode(1, 3),
episode(1, 4),
)
val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
requestedSeason = 2,
requestedEpisode = 1,
requestedVideoId = null,
requestedTitle = null,
addonEpisodes = addon,
traktEpisodes = trakt,
)
assertEquals(1, mapped?.season)
assertEquals(3, mapped?.episode)
}
@Test
fun `reverse mapping uses global sorted index for Trakt absolute numbering`() {
val addon = listOf(
episode(1, 1),
episode(1, 2),
episode(2, 1),
episode(2, 2),
)
val trakt = listOf(
episode(1, 1),
episode(1, 2),
episode(1, 3),
episode(1, 4),
)
val mapped = TraktEpisodeMappingService.reverseRemapEpisodeByTitleOrIndex(
requestedSeason = 1,
requestedEpisode = 3,
requestedTitle = null,
addonEpisodes = addon,
traktEpisodes = trakt,
)
assertEquals(2, mapped?.season)
assertEquals(1, mapped?.episode)
}
@Test
fun `unique normalized title wins over index`() {
val addon = listOf(
episode(1, 1, title = "The Storm"),
episode(1, 2, title = "Aftermath"),
)
val trakt = listOf(
episode(1, 1, title = "Aftermath"),
episode(1, 2, title = "The Storm!"),
)
val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
requestedSeason = 1,
requestedEpisode = 1,
requestedVideoId = null,
requestedTitle = null,
addonEpisodes = addon,
traktEpisodes = trakt,
)
assertEquals(1, mapped?.season)
assertEquals(2, mapped?.episode)
}
@Test
fun `generic title falls back to index`() {
val addon = listOf(
episode(1, 1, title = "Episode 1"),
episode(2, 1, title = "Actual Title"),
)
val trakt = listOf(
episode(1, 1, title = "Actual Title"),
episode(1, 2, title = "Episode 1"),
)
val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
requestedSeason = 1,
requestedEpisode = 1,
requestedVideoId = null,
requestedTitle = null,
addonEpisodes = addon,
traktEpisodes = trakt,
)
assertEquals(1, mapped?.season)
assertEquals(1, mapped?.episode)
}
@Test
fun `duplicate title falls back to index`() {
val addon = listOf(
episode(1, 1, title = "Pilot"),
episode(2, 1, title = "Other"),
)
val trakt = listOf(
episode(1, 1, title = "Pilot"),
episode(1, 2, title = "Pilot"),
)
val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
requestedSeason = 1,
requestedEpisode = 1,
requestedVideoId = null,
requestedTitle = null,
addonEpisodes = addon,
traktEpisodes = trakt,
)
assertEquals(1, mapped?.season)
assertEquals(1, mapped?.episode)
}
@Test
fun `video id selects source episode before season episode`() {
val addon = listOf(
episode(1, 1, videoId = "show:1:1"),
episode(2, 1, videoId = "show:2:1"),
)
val trakt = listOf(
episode(1, 1),
episode(1, 2),
)
val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
requestedSeason = 1,
requestedEpisode = 1,
requestedVideoId = "show:2:1",
requestedTitle = null,
addonEpisodes = addon,
traktEpisodes = trakt,
)
assertEquals(1, mapped?.season)
assertEquals(2, mapped?.episode)
}
@Test
fun `index outside target range returns null`() {
val addon = listOf(
episode(1, 1),
episode(1, 2),
)
val trakt = listOf(episode(1, 1))
val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
requestedSeason = 1,
requestedEpisode = 2,
requestedVideoId = null,
requestedTitle = null,
addonEpisodes = addon,
traktEpisodes = trakt,
)
assertNull(mapped)
}
private fun episode(
season: Int,
episode: Int,
title: String? = null,
videoId: String? = null,
) = EpisodeMappingEntry(
season = season,
episode = episode,
title = title,
videoId = videoId,
)
}

View file

@ -97,6 +97,30 @@ class SeriesContinuityTest {
assertEquals("show:1:1", action.videoId)
}
@Test
fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() {
val episodesWithSpecials = listOf(
WatchingReleasedEpisode(videoId = "sp1", seasonNumber = 0, episodeNumber = 1, title = "Special 1", releasedDate = "2026-01-01"),
WatchingReleasedEpisode(videoId = "s1e1", seasonNumber = 1, episodeNumber = 1, title = "Episode 1", releasedDate = "2026-01-08"),
WatchingReleasedEpisode(videoId = "s1e2", seasonNumber = 1, episodeNumber = 2, title = "Episode 2", releasedDate = "2026-01-15"),
WatchingReleasedEpisode(videoId = "s2e1", seasonNumber = 2, episodeNumber = 1, title = "Episode 3", releasedDate = "2026-01-22"),
WatchingReleasedEpisode(videoId = "s2e2", seasonNumber = 2, episodeNumber = 2, title = "Episode 4", releasedDate = "2026-01-29"),
)
val nextEpisode = nextReleasedEpisodeAfter(
content = show,
episodes = episodesWithSpecials,
seasonNumber = 1,
episodeNumber = 3,
todayIsoDate = "2026-02-01",
)
assertNotNull(nextEpisode)
assertEquals(2, nextEpisode.seasonNumber)
assertEquals(2, nextEpisode.episodeNumber)
assertEquals("s2e2", nextEpisode.videoId)
}
@Test
fun decideSeriesPrimaryAction_falls_back_to_specials_when_no_main_season() {
val specialsOnly = listOf(

View file

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

View file

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

View file

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

View file

@ -0,0 +1,23 @@
package com.nuvio.app.features.profiles
import platform.UIKit.UISelectionFeedbackGenerator
internal actual object ProfileHoverHapticFeedback {
private var generator: UISelectionFeedbackGenerator? = null
actual fun prepare() {
generator = UISelectionFeedbackGenerator().also { it.prepare() }
}
actual fun perform() {
val activeGenerator = generator ?: UISelectionFeedbackGenerator().also {
generator = it
}
activeGenerator.selectionChanged()
activeGenerator.prepare()
}
actual fun release() {
generator = null
}
}

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.introdb_favicon
import nuvio.composeapp.generated.resources.mdblist_logo
import nuvio.composeapp.generated.resources.rating_tmdb
import nuvio.composeapp.generated.resources.trakt_tv_favicon
@ -14,4 +15,5 @@ internal actual fun integrationLogoPainter(logo: IntegrationLogo): Painter =
IntegrationLogo.Tmdb -> painterResource(Res.drawable.rating_tmdb)
IntegrationLogo.Trakt -> painterResource(Res.drawable.trakt_tv_favicon)
IntegrationLogo.MdbList -> painterResource(Res.drawable.mdblist_logo)
IntegrationLogo.IntroDb -> painterResource(Res.drawable.introdb_favicon)
}

View file

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

View file

@ -1,14 +1,14 @@
#Kotlin
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx4096M
kotlin.native.jvmArgs=-Xmx6144M
kotlin.daemon.jvmargs=-Xmx6144M
kotlin.native.jvmArgs=-Xmx12288M
kotlin.mpp.enableCInteropCommonization=true
#Gradle
org.gradle.jvmargs=-Xmx6144M -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=1024m
org.gradle.jvmargs=-Xmx8192M -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=1536m
org.gradle.configuration-cache=true
org.gradle.caching=true
#Android
android.nonTransitiveRClass=true
android.useAndroidX=true
android.useAndroidX=true

View file

@ -51,6 +51,7 @@ compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-previ
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=54
CURRENT_PROJECT_VERSION=58
MARKETING_VERSION=0.1.0