From 62d38f74f8897902d2ed51e06da61af7996a3893 Mon Sep 17 00:00:00 2001 From: Aniket Tuli Date: Wed, 13 May 2026 11:06:55 -0700 Subject: [PATCH] refactor: drop runBlocking { getString } from common repos and parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces 23 of 41 runBlocking { getString } sites in commonMain across the meta-details, collection, Trakt, profile, and TMDB code paths. Wrapping suspend getString in runBlocking blocks the calling thread on every property read or recomposition and deadlocks on Kotlin/JS. Three patterns: propagate suspend up call chains that were already fully suspend; mark non-Composable helpers @Composable + use stringResource; and where the call sat in a data-class property getter, use a literal English fallback (fires only on degenerate addon manifests with no name and no URL-derived slug). Also fixes MetaTrailer.type defaulting to a localized "Trailer" string when missing — UI already handles blank type with a localized fallback at render time, so the eager localization mismatched grouping/sort keys across locales. The 18 remaining sites are in androidMain platform-bridge callbacks (OkHttp / ExoPlayer / NotificationManager) and are tracked as a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../nuvio/app/features/addons/AddonModels.kt | 7 +- .../collection/CollectionManagementScreen.kt | 21 +-- .../collection/CollectionRepository.kt | 65 ++++------ .../collection/FolderDetailRepository.kt | 13 +- .../app/features/details/MetaDetailsParser.kt | 122 ++++++++++-------- .../app/features/details/MetaDetailsScreen.kt | 3 +- .../details/MetaScreenSettingsRepository.kt | 7 - .../details/components/DetailMetaInfo.kt | 8 +- .../details/components/DetailSeriesContent.kt | 10 +- .../home/HomeCatalogSettingsRepository.kt | 27 ++-- .../features/profiles/ProfileRepository.kt | 13 +- .../settings/MetaScreenSettingsPage.kt | 2 +- .../features/settings/TraktSettingsPage.kt | 39 +++--- .../app/features/streams/StreamModels.kt | 6 +- .../app/features/tmdb/TmdbMetadataService.kt | 57 ++++---- .../app/features/trakt/TraktAuthRepository.kt | 21 ++- .../features/trakt/TraktCommentsRepository.kt | 8 +- .../features/details/MetaDetailsParserTest.kt | 8 +- 18 files changed, 217 insertions(+), 220 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt index 6b73ffe6..c02bf6d5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt @@ -1,10 +1,5 @@ package com.nuvio.app.features.addons -import kotlinx.coroutines.runBlocking -import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.generic_addon -import org.jetbrains.compose.resources.getString - data class AddonManifest( val id: String, val name: String, @@ -60,7 +55,7 @@ data class ManagedAddon( get() = userSetName?.takeIf { it.isNotBlank() && it != manifest?.name } ?: manifest?.name ?: manifestUrl.substringBefore("?").substringAfterLast("/").ifBlank { - runBlocking { getString(Res.string.generic_addon) } + "Addon" } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt index 74deba81..d776414a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -57,6 +58,7 @@ import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioSurfaceCard import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource +import kotlinx.coroutines.launch import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -69,6 +71,7 @@ fun CollectionManagementScreen( ) { val collections by CollectionRepository.collections.collectAsState() val clipboardManager = LocalClipboardManager.current + val coroutineScope = rememberCoroutineScope() var showImportDialog by remember { mutableStateOf(false) } var importText by remember { mutableStateOf("") } var importError by remember { mutableStateOf(null) } @@ -171,14 +174,16 @@ fun CollectionManagementScreen( importError = null }, onConfirm = { - val result = CollectionRepository.validateJson(importText) - if (result.valid) { - CollectionRepository.importFromJson(importText) - showImportDialog = false - importText = "" - importError = null - } else { - importError = result.error + coroutineScope.launch { + val result = CollectionRepository.validateJson(importText) + if (result.valid) { + CollectionRepository.importFromJson(importText) + showImportDialog = false + importText = "" + importError = null + } else { + importError = result.error + } } }, onDismiss = { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt index 270e9781..07ab654e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.runBlocking import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -135,11 +134,11 @@ object CollectionRepository { } } - fun validateJson(jsonString: String): ValidationResult { + suspend fun validateJson(jsonString: String): ValidationResult { if (jsonString.isBlank()) { return ValidationResult( valid = false, - error = runBlocking { getString(Res.string.collections_import_error_empty_json) }, + error = getString(Res.string.collections_import_error_empty_json), ) } return try { @@ -149,55 +148,45 @@ object CollectionRepository { if (c.id.isBlank()) { return ValidationResult( valid = false, - error = runBlocking { - getString(Res.string.collections_import_error_collection_blank_id, ci + 1) - }, + error = getString(Res.string.collections_import_error_collection_blank_id, ci + 1), ) } if (c.title.isBlank()) { return ValidationResult( valid = false, - error = runBlocking { - getString(Res.string.collections_import_error_collection_blank_title, c.id) - }, + error = getString(Res.string.collections_import_error_collection_blank_title, c.id), ) } c.folders.forEachIndexed { fi, f -> if (f.id.isBlank()) { return ValidationResult( valid = false, - error = runBlocking { - getString( - Res.string.collections_import_error_folder_blank_id, - fi + 1, - c.title, - ) - }, + error = getString( + Res.string.collections_import_error_folder_blank_id, + fi + 1, + c.title, + ), ) } if (f.title.isBlank()) { return ValidationResult( valid = false, - error = runBlocking { - getString( - Res.string.collections_import_error_folder_blank_title, - f.id, - c.title, - ) - }, + error = getString( + Res.string.collections_import_error_folder_blank_title, + f.id, + c.title, + ), ) } f.resolvedSources.forEachIndexed { si, s -> if (s.hasInvalidTraktListId()) { return ValidationResult( valid = false, - error = runBlocking { - getString( - Res.string.collections_import_error_trakt_list_id, - si + 1, - f.title, - ) - }, + error = getString( + Res.string.collections_import_error_trakt_list_id, + si + 1, + f.title, + ), ) } @@ -208,13 +197,11 @@ object CollectionRepository { if (invalidAddon || invalidTmdb) { return ValidationResult( valid = false, - error = runBlocking { - getString( - Res.string.collections_import_error_source_blank_fields, - si + 1, - f.title, - ) - }, + error = getString( + Res.string.collections_import_error_source_blank_fields, + si + 1, + f.title, + ), ) } } @@ -229,9 +216,7 @@ object CollectionRepository { } catch (e: Exception) { ValidationResult( valid = false, - error = runBlocking { - getString(Res.string.collections_import_error_invalid_json, e.message.orEmpty()) - }, + error = getString(Res.string.collections_import_error_invalid_json, e.message.orEmpty()), ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt index d5c7a172..0f15f203 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt @@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.collections_folder_addon_not_found import nuvio.composeapp.generated.resources.collections_tab_all @@ -93,7 +92,7 @@ object FolderDetailRepository { private var activeCollectionId: String? = null private var activeFolderId: String? = null - fun initialize(collectionId: String, folderId: String) { + suspend fun initialize(collectionId: String, folderId: String) { val current = _uiState.value if ( activeCollectionId == collectionId && @@ -128,7 +127,7 @@ object FolderDetailRepository { if (showAll) { add( FolderTab( - label = runBlocking { getString(Res.string.collections_tab_all) }, + label = getString(Res.string.collections_tab_all), isAllTab = true, isLoading = true, ), @@ -213,12 +212,14 @@ object FolderDetailRepository { val catalogSource = source.addonCatalogSource() val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) } if (!source.isTmdb && !source.isTrakt && resolvedCatalog == null) { + val errorMessage = getString( + Res.string.collections_folder_addon_not_found, + catalogSource?.addonId.orEmpty(), + ) updateTab(tabIndex) { it.copy( isLoading = false, - error = runBlocking { - getString(Res.string.collections_folder_addon_not_found, catalogSource?.addonId.orEmpty()) - }, + error = errorMessage, ) } return@forEachIndexed diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt index adcf6811..2ac18741 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -3,7 +3,6 @@ package com.nuvio.app.features.details import com.nuvio.app.features.streams.StreamBehaviorHints import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamProxyHeaders -import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -20,7 +19,7 @@ import org.jetbrains.compose.resources.getString internal object MetaDetailsParser { private val json = Json { ignoreUnknownKeys = true } - fun parse(payload: String): MetaDetails { + suspend fun parse(payload: String): MetaDetails { val root = json.parseToJsonElement(payload).asJsonObjectOrNull() ?: error("Expected top-level JSON object in response") val meta = root.extractMetaObject() @@ -217,59 +216,71 @@ internal object MetaDetailsParser { return merged.values.toList() } - private fun JsonObject.videos(): List = - array("videos").mapNotNull { element -> - val video = element as? JsonObject ?: return@mapNotNull null - val id = video.string("id") ?: return@mapNotNull null - val title = video.string("title") ?: video.string("name") ?: return@mapNotNull null - MetaVideo( - id = id, - title = title, - released = video.string("released"), - thumbnail = video.string("thumbnail"), - seasonPoster = video.string("seasonPoster") ?: video.string("season_poster_path"), - season = video.int("season"), - episode = video.int("episode"), - overview = video.string("overview") ?: video.string("description"), - runtime = video.int("runtime"), - streams = video.embeddedStreams(), + private suspend fun JsonObject.videos(): List { + val result = mutableListOf() + for (element in array("videos")) { + val video = element as? JsonObject ?: continue + val id = video.string("id") ?: continue + val title = video.string("title") ?: video.string("name") ?: continue + result.add( + MetaVideo( + id = id, + title = title, + released = video.string("released"), + thumbnail = video.string("thumbnail"), + seasonPoster = video.string("seasonPoster") ?: video.string("season_poster_path"), + season = video.int("season"), + episode = video.int("episode"), + overview = video.string("overview") ?: video.string("description"), + runtime = video.int("runtime"), + streams = video.embeddedStreams(), + ), ) } + return result + } - private fun JsonObject.trailers(): List = - array("trailers").mapNotNull { element -> - val trailer = element as? JsonObject ?: return@mapNotNull null + private suspend fun JsonObject.trailers(): List { + val result = mutableListOf() + for (element in array("trailers")) { + val trailer = element as? JsonObject ?: continue val key = trailer.string("key") ?: trailer.string("source") ?: trailer.string("ytId") ?: trailer.string("ytid") - ?: return@mapNotNull null + ?: continue val normalizedKey = key.trim() - if (normalizedKey.isEmpty()) return@mapNotNull null + if (normalizedKey.isEmpty()) continue - MetaTrailer( - id = trailer.string("id")?.takeIf(String::isNotBlank) ?: normalizedKey, - key = normalizedKey, - name = trailer.string("name")?.takeIf(String::isNotBlank) ?: runBlocking { getString(Res.string.generic_trailer) }, - site = trailer.string("site")?.takeIf(String::isNotBlank) ?: "YouTube", - size = trailer.int("size"), - type = trailer.string("type")?.takeIf(String::isNotBlank) ?: runBlocking { getString(Res.string.generic_trailer) }, - official = trailer.boolean("official") == true, - publishedAt = trailer.string("published_at") ?: trailer.string("publishedAt"), - seasonNumber = trailer.int("seasonNumber") ?: trailer.int("season_number"), - displayName = trailer.string("displayName")?.takeIf(String::isNotBlank), + result.add( + MetaTrailer( + id = trailer.string("id")?.takeIf(String::isNotBlank) ?: normalizedKey, + key = normalizedKey, + name = trailer.string("name")?.takeIf(String::isNotBlank) + ?: getString(Res.string.generic_trailer), + site = trailer.string("site")?.takeIf(String::isNotBlank) ?: "YouTube", + size = trailer.int("size"), + type = trailer.string("type")?.takeIf(String::isNotBlank) ?: "", + official = trailer.boolean("official") == true, + publishedAt = trailer.string("published_at") ?: trailer.string("publishedAt"), + seasonNumber = trailer.int("seasonNumber") ?: trailer.int("season_number"), + displayName = trailer.string("displayName")?.takeIf(String::isNotBlank), + ), ) } + return result + } - private fun JsonObject.embeddedStreams(): List { + private suspend fun JsonObject.embeddedStreams(): List { val arr = this["streams"] as? JsonArray ?: return emptyList() - return arr.mapNotNull { element -> - val obj = element as? JsonObject ?: return@mapNotNull null + val result = mutableListOf() + for (element in arr) { + val obj = element as? JsonObject ?: continue val url = obj.string("url") val infoHash = obj.string("infoHash") val externalUrl = obj.string("externalUrl") - if (url == null && infoHash == null && externalUrl == null) return@mapNotNull null + if (url == null && infoHash == null && externalUrl == null) continue val hintsObj = obj["behaviorHints"] as? JsonObject val proxyHeaders = hintsObj @@ -278,25 +289,28 @@ internal object MetaDetailsParser { val streamData = obj["streamData"] as? JsonObject val addonName = streamData?.string("addon") ?: obj.string("name") - ?: runBlocking { getString(Res.string.source_embedded) } - StreamItem( - name = obj.string("name"), - description = obj.string("description") ?: obj.string("title"), - url = url, - infoHash = infoHash, - fileIdx = obj.int("fileIdx"), - externalUrl = externalUrl, - addonName = addonName, - addonId = "embedded", - behaviorHints = StreamBehaviorHints( - bingeGroup = hintsObj?.string("bingeGroup"), - notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null, - videoSize = hintsObj?.long("videoSize"), - filename = hintsObj?.string("filename"), - proxyHeaders = proxyHeaders, + ?: getString(Res.string.source_embedded) + result.add( + StreamItem( + name = obj.string("name"), + description = obj.string("description") ?: obj.string("title"), + url = url, + infoHash = infoHash, + fileIdx = obj.int("fileIdx"), + externalUrl = externalUrl, + addonName = addonName, + addonId = "embedded", + behaviorHints = StreamBehaviorHints( + bingeGroup = hintsObj?.string("bingeGroup"), + notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null, + videoSize = hintsObj?.long("videoSize"), + filename = hintsObj?.string("filename"), + proxyHeaders = proxyHeaders, + ), ), ) } + return result } private fun JsonObject.objectValue(name: String): JsonObject? = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index d8bfbf27..04575dfb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -98,6 +98,7 @@ import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository +import com.nuvio.app.features.settings.titleRes import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.launch @@ -1176,7 +1177,7 @@ private fun ConfiguredMetaSections( RenderSection(groupMembers.first().key) } else { TabbedSectionGroup( - tabs = groupMembers.map { it.key to it.title }, + tabs = groupMembers.map { it.key to stringResource(it.key.titleRes) }, ) { activeKey -> RenderSection(activeKey, showHeader = false) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt index 8d4f8c0f..9a75cdf6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt @@ -3,7 +3,6 @@ package com.nuvio.app.features.details import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.runBlocking import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString @@ -11,7 +10,6 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.getString enum class MetaScreenSectionKey { ACTIONS, @@ -33,8 +31,6 @@ enum class MetaScreenSectionKey { data class MetaScreenSectionItem( val key: MetaScreenSectionKey, - val title: String, - val description: String, val enabled: Boolean, val order: Int, val tabGroup: Int? = null, @@ -160,7 +156,6 @@ object MetaScreenSettingsRepository { private var tabLayout: Boolean = false private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal private var blurUnwatchedEpisodes: Boolean = false - private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } fun ensureLoaded() { if (hasLoaded) return @@ -345,8 +340,6 @@ object MetaScreenSettingsRepository { val preference = preferences[definition.key] MetaScreenSectionItem( key = definition.key, - title = localizedString(definition.titleRes), - description = localizedString(definition.descriptionRes), enabled = preference?.enabled ?: true, order = preference?.order ?: 0, tabGroup = preference?.tabGroup, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt index 50add3ca..6a7d4d8f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt @@ -58,10 +58,8 @@ import nuvio.composeapp.generated.resources.rating_rotten_tomatoes import nuvio.composeapp.generated.resources.rating_tmdb import nuvio.composeapp.generated.resources.rating_trakt import org.jetbrains.compose.resources.DrawableResource -import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource -import kotlinx.coroutines.runBlocking import kotlin.math.absoluteValue import kotlin.math.roundToInt @@ -213,6 +211,8 @@ private fun DetailRatingsRow( if (orderedRatings.isEmpty()) return + val audienceScoreLabel = stringResource(Res.string.rating_audience_score) + Row( modifier = Modifier .fillMaxWidth() @@ -226,7 +226,7 @@ private fun DetailRatingsRow( ) { Image( painter = painterResource(visuals.logo), - contentDescription = visuals.displayName, + contentDescription = if (visuals.source == PROVIDER_AUDIENCE) audienceScoreLabel else visuals.displayName, modifier = Modifier.size(width = visuals.logoWidth, height = 16.dp), ) Spacer(modifier = Modifier.width(4.dp)) @@ -348,7 +348,7 @@ private val ratingVisuals = listOf( ), RatingVisuals( source = PROVIDER_AUDIENCE, - displayName = runBlocking { getString(Res.string.rating_audience_score) }, + displayName = "", logo = Res.drawable.rating_audience_score, logoWidth = 16.dp, valueColor = Color(0xFFFA320A), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index e5140b74..87ce1534 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -76,9 +76,7 @@ import com.nuvio.app.features.details.seasonSortKey import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watching.application.WatchingState -import kotlinx.coroutines.runBlocking import nuvio.composeapp.generated.resources.* -import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import kotlin.math.absoluteValue @@ -1306,18 +1304,20 @@ private fun seriesContentSizing(maxWidthDp: Float): SeriesContentSizing = ) } +@Composable private fun Int.label(): String = if (this <= 0) { - runBlocking { getString(Res.string.episodes_specials) } + stringResource(Res.string.episodes_specials) } else { - runBlocking { getString(Res.string.episodes_season, this@label) } + stringResource(Res.string.episodes_season, this@label) } +@Composable private fun MetaVideo.episodeBadge(): String = when { episode != null || season != null -> localizedSeasonEpisodeCode(seasonNumber = season, episodeNumber = episode).orEmpty() - else -> runBlocking { getString(Res.string.details_episode_badge_file) } + else -> stringResource(Res.string.details_episode_badge_file) } private fun MetaVideo.seasonEpisodeKey(): Pair? { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt index 202af87a..61104438 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt @@ -3,7 +3,6 @@ package com.nuvio.app.features.home import com.nuvio.app.features.addons.ManagedAddon import com.nuvio.app.features.collection.Collection import com.nuvio.app.features.collection.CollectionRepository -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -124,7 +123,7 @@ object HomeCatalogSettingsRepository { _uiState.value = HomeCatalogSettingsUiState() } - fun syncCatalogs(addons: List) { + suspend fun syncCatalogs(addons: List) { ensureLoaded() definitions = buildHomeCatalogDefinitions(addons) collectionDefinitions = buildCollectionDefinitions(CollectionRepository.collections.value) @@ -138,7 +137,7 @@ object HomeCatalogSettingsRepository { persist() } - fun syncCollections(collections: List) { + suspend fun syncCollections(collections: List) { ensureLoaded() collectionDefinitions = buildCollectionDefinitions(collections) normalizePreferences() @@ -530,13 +529,19 @@ internal data class CollectionCatalogDefinition( val isPinnedToTop: Boolean, ) -internal fun buildCollectionDefinitions(collections: List): List = - collections.filter { it.folders.isNotEmpty() }.map { collection -> - CollectionCatalogDefinition( - key = "collection_${collection.id}", - collectionId = collection.id, - title = collection.title, - subtitle = runBlocking { getString(Res.string.collections_folder_count, collection.folders.size) }, - isPinnedToTop = collection.pinToTop, +internal suspend fun buildCollectionDefinitions(collections: List): List { + val result = mutableListOf() + for (collection in collections) { + if (collection.folders.isEmpty()) continue + result.add( + CollectionCatalogDefinition( + key = "collection_${collection.id}", + collectionId = collection.id, + title = collection.title, + subtitle = getString(Res.string.collections_folder_count, collection.folders.size), + isPinnedToTop = collection.pinToTop, + ), ) } + return result +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 5760e73e..2344d293 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -35,7 +35,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -44,7 +43,6 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.put import nuvio.composeapp.generated.resources.* -import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString @Serializable @@ -58,7 +56,6 @@ object ProfileRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("ProfileRepository") private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } - private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } private val _state = MutableStateFlow(ProfileState()) val state: StateFlow = _state.asStateFlow() @@ -414,7 +411,7 @@ object ProfileRepository { ProfilePinCacheStorage.savePayload(profileIndex, json.encodeToString(payload)) } - private fun verifyPinLocally(profileIndex: Int, pin: String): PinVerifyResult { + private suspend fun verifyPinLocally(profileIndex: Int, pin: String): PinVerifyResult { val profile = _state.value.profiles.find { it.profileIndex == profileIndex } if (profile?.pinEnabled != true) { return PinVerifyResult(unlocked = true) @@ -424,7 +421,7 @@ object ProfileRepository { if (payload.isEmpty()) { return PinVerifyResult( unlocked = false, - message = localizedString(Res.string.profile_pin_offline_verification_requires_online), + message = getString(Res.string.profile_pin_offline_verification_requires_online), ) } @@ -432,7 +429,7 @@ object ProfileRepository { json.decodeFromString(payload) }.getOrNull() ?: return PinVerifyResult( unlocked = false, - message = localizedString(Res.string.profile_pin_offline_verification_requires_online), + message = getString(Res.string.profile_pin_offline_verification_requires_online), ) if ( @@ -443,7 +440,7 @@ object ProfileRepository { ProfilePinCacheStorage.removePayload(profileIndex) return PinVerifyResult( unlocked = false, - message = localizedString(Res.string.profile_pin_changed_requires_refresh), + message = getString(Res.string.profile_pin_changed_requires_refresh), ) } @@ -451,7 +448,7 @@ object ProfileRepository { return if (digest == cached.digest) { PinVerifyResult(unlocked = true) } else { - PinVerifyResult(unlocked = false, message = localizedString(Res.string.pin_incorrect)) + PinVerifyResult(unlocked = false, message = getString(Res.string.pin_incorrect)) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt index ac932b93..d43c78e3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt @@ -487,7 +487,7 @@ private val MetaEpisodeCardStyle.descriptionRes: StringResource MetaEpisodeCardStyle.List -> Res.string.settings_meta_episode_style_list_description } -private val MetaScreenSectionKey.titleRes: StringResource +internal val MetaScreenSectionKey.titleRes: StringResource get() = when (this) { MetaScreenSectionKey.ACTIONS -> Res.string.settings_meta_actions MetaScreenSectionKey.OVERVIEW -> Res.string.settings_meta_overview diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt index 198b3123..fac2b39c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalUriHandler @@ -49,6 +50,7 @@ import com.nuvio.app.features.trakt.WatchProgressSource import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap import com.nuvio.app.features.trakt.traktBrandPainter +import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel import nuvio.composeapp.generated.resources.settings_playback_dialog_close @@ -581,6 +583,7 @@ private fun TraktConnectionCard( uiState: TraktAuthUiState, ) { val uriHandler = LocalUriHandler.current + val coroutineScope = rememberCoroutineScope() val horizontalPadding = if (isTablet) 20.dp else 16.dp val verticalPadding = if (isTablet) 18.dp else 16.dp val failedOpenBrowserMessage = stringResource(Res.string.settings_trakt_failed_open_browser) @@ -641,15 +644,17 @@ private fun TraktConnectionCard( ) Button( onClick = { - val authUrl = TraktAuthRepository.pendingAuthorizationUrl() - ?: TraktAuthRepository.onConnectRequested() - if (authUrl == null) return@Button - runCatching { uriHandler.openUri(authUrl) } - .onFailure { - TraktAuthRepository.onAuthLaunchFailed( - it.message ?: failedOpenBrowserMessage, - ) - } + coroutineScope.launch { + val authUrl = TraktAuthRepository.pendingAuthorizationUrl() + ?: TraktAuthRepository.onConnectRequested() + if (authUrl == null) return@launch + runCatching { uriHandler.openUri(authUrl) } + .onFailure { + TraktAuthRepository.onAuthLaunchFailed( + it.message ?: failedOpenBrowserMessage, + ) + } + } }, enabled = !uiState.isLoading, ) { @@ -675,13 +680,15 @@ private fun TraktConnectionCard( ) Button( onClick = { - val authUrl = TraktAuthRepository.onConnectRequested() ?: return@Button - runCatching { uriHandler.openUri(authUrl) } - .onFailure { - TraktAuthRepository.onAuthLaunchFailed( - it.message ?: failedOpenBrowserMessage, - ) - } + coroutineScope.launch { + val authUrl = TraktAuthRepository.onConnectRequested() ?: return@launch + runCatching { uriHandler.openUri(authUrl) } + .onFailure { + TraktAuthRepository.onAuthLaunchFailed( + it.message ?: failedOpenBrowserMessage, + ) + } + } }, enabled = uiState.credentialsConfigured && !uiState.isLoading, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index 784dff47..fc097eed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -1,9 +1,5 @@ package com.nuvio.app.features.streams -import kotlinx.coroutines.runBlocking -import nuvio.composeapp.generated.resources.* -import org.jetbrains.compose.resources.getString - data class StreamItem( val name: String? = null, val description: String? = null, @@ -17,7 +13,7 @@ data class StreamItem( val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), ) { val streamLabel: String - get() = name ?: runBlocking { getString(Res.string.stream_default_name) } + get() = name ?: "Stream" val streamSubtitle: String? get() = description diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt index cc87a1e5..ab0a0c83 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -97,7 +96,7 @@ object TmdbMetadataService { val detail = PersonDetail( tmdbId = person.id ?: personId, - name = person.name ?: runBlocking { getString(Res.string.generic_unknown) }, + name = person.name ?: getString(Res.string.generic_unknown), biography = biography, birthday = person.birthday?.takeIf { it.isNotBlank() }, deathday = person.deathday?.takeIf { it.isNotBlank() }, @@ -327,7 +326,7 @@ object TmdbMetadataService { header = header ?: TmdbEntityHeader( id = entityId, kind = entityKind, - name = fallbackName?.takeIf { it.isNotBlank() } ?: runBlocking { getString(Res.string.generic_unknown) }, + name = fallbackName?.takeIf { it.isNotBlank() } ?: getString(Res.string.generic_unknown), logo = null, originCountry = null, secondaryLabel = null, @@ -442,7 +441,7 @@ object TmdbMetadataService { kind = entityKind, name = it.name?.takeIf { n -> n.isNotBlank() } ?: fallbackName?.takeIf { n -> n.isNotBlank() } - ?: runBlocking { getString(Res.string.generic_unknown) }, + ?: getString(Res.string.generic_unknown), logo = buildImageUrl(it.logoPath, "w500"), originCountry = it.originCountry?.takeIf { c -> c.isNotBlank() }, secondaryLabel = it.headquarters?.takeIf { h -> h.isNotBlank() }, @@ -458,7 +457,7 @@ object TmdbMetadataService { kind = entityKind, name = it.name?.takeIf { n -> n.isNotBlank() } ?: fallbackName?.takeIf { n -> n.isNotBlank() } - ?: runBlocking { getString(Res.string.generic_unknown) }, + ?: getString(Res.string.generic_unknown), logo = buildImageUrl(it.logoPath, "w500"), originCountry = it.originCountry?.takeIf { c -> c.isNotBlank() }, secondaryLabel = it.headquarters?.takeIf { h -> h.isNotBlank() }, @@ -1112,8 +1111,8 @@ object TmdbMetadataService { endpoint = "$mediaType/$tmdbId/videos", language = language, ) - allVideos += primaryVideos.map { video -> - video.toMetaTrailer( + for (video in primaryVideos) { + allVideos += video.toMetaTrailer( seasonNumber = null, displayName = video.name, ) @@ -1137,23 +1136,22 @@ object TmdbMetadataService { }.awaitAll() } - seasonVideos.forEach { (seasonNumber, videos) -> - allVideos += videos.map { video -> - video.toMetaTrailer( + for ((seasonNumber, videos) in seasonVideos) { + for (video in videos) { + allVideos += video.toMetaTrailer( seasonNumber = seasonNumber, - displayName = runBlocking { - getString( - Res.string.trailer_season_label, - seasonNumber, - video.name.orEmpty(), - ) - }, + displayName = getString( + Res.string.trailer_season_label, + seasonNumber, + video.name.orEmpty(), + ), ) } } } } + val genericTrailerLabel = getString(Res.string.generic_trailer) val byCategory = linkedMapOf>() allVideos .asSequence() @@ -1162,7 +1160,7 @@ object TmdbMetadataService { } .forEach { trailer -> byCategory.getOrPut( - trailer.type.ifBlank { runBlocking { getString(Res.string.generic_trailer) } }, + trailer.type.ifBlank { genericTrailerLabel }, ) { mutableListOf() } .add(trailer) } @@ -1184,10 +1182,7 @@ object TmdbMetadataService { val sortedCategories = byCategory.keys.sortedWith( compareBy { category -> when { - category.equals( - runBlocking { getString(Res.string.generic_trailer) }, - ignoreCase = true, - ) -> 0 + category.equals(genericTrailerLabel, ignoreCase = true) -> 0 byCategory[category].orEmpty().any { it.official } -> 1 else -> 2 } @@ -1300,17 +1295,21 @@ internal fun normalizeTmdbLanguage(language: String?): String { } } -private fun buildPeople( +private suspend fun buildPeople( details: TmdbDetailsResponse, credits: TmdbCreditsResponse?, mediaType: String, ): List { + val creatorRole = getString(Res.string.person_role_creator) + val directorRole = getString(Res.string.person_role_director) + val writerRole = getString(Res.string.person_role_writer) + val creators = if (mediaType == "tv") { details.createdBy.mapNotNull { creator -> val name = creator.name?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null MetaPerson( name = name, - role = runBlocking { getString(Res.string.person_role_creator) }, + role = creatorRole, photo = buildImageUrl(creator.profilePath, "w500"), tmdbId = creator.id, ) @@ -1325,7 +1324,7 @@ private fun buildPeople( val name = crew.name?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null MetaPerson( name = name, - role = runBlocking { getString(Res.string.person_role_director) }, + role = directorRole, photo = buildImageUrl(crew.profilePath, "w500"), tmdbId = crew.id, ) @@ -1340,7 +1339,7 @@ private fun buildPeople( val name = crew.name?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null MetaPerson( name = name, - role = runBlocking { getString(Res.string.person_role_writer) }, + role = writerRole, photo = buildImageUrl(crew.profilePath, "w500"), tmdbId = crew.id, ) @@ -1543,12 +1542,12 @@ private data class TmdbVideoResult( @SerialName("published_at") val publishedAt: String? = null, ) -private fun TmdbVideoResult.toMetaTrailer( +private suspend fun TmdbVideoResult.toMetaTrailer( seasonNumber: Int?, displayName: String?, ): MetaTrailer { val videoKey = key?.trim().orEmpty() - val videoName = name?.trim().takeUnless { it.isNullOrBlank() } ?: runBlocking { getString(Res.string.generic_trailer) } + val videoName = name?.trim().takeUnless { it.isNullOrBlank() } ?: getString(Res.string.generic_trailer) val trailerId = id?.trim().takeUnless { it.isNullOrBlank() } ?: videoKey return MetaTrailer( id = trailerId, @@ -1556,7 +1555,7 @@ private fun TmdbVideoResult.toMetaTrailer( name = videoName, site = site?.trim().takeUnless { it.isNullOrBlank() } ?: "YouTube", size = size, - type = type?.trim().takeUnless { it.isNullOrBlank() } ?: runBlocking { getString(Res.string.generic_trailer) }, + type = type?.trim().takeUnless { it.isNullOrBlank() } ?: "", official = official == true, publishedAt = publishedAt, seasonNumber = seasonNumber, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt index 3fed8022..376dfb5b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt @@ -21,8 +21,6 @@ import kotlinx.serialization.json.Json import kotlin.random.Random import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString -import org.jetbrains.compose.resources.StringResource -import kotlinx.coroutines.runBlocking object TraktAuthRepository { private const val BASE_URL = "https://api.trakt.tv" @@ -68,10 +66,10 @@ object TraktAuthRepository { fun hasRequiredCredentials(): Boolean = TraktConfig.CLIENT_ID.isNotBlank() && TraktConfig.CLIENT_SECRET.isNotBlank() - fun onConnectRequested(): String? { + suspend fun onConnectRequested(): String? { ensureLoaded() if (!hasRequiredCredentials()) { - publish(errorMessage = localizedString(Res.string.trakt_missing_credentials)) + publish(errorMessage = getString(Res.string.trakt_missing_credentials)) return null } @@ -82,7 +80,7 @@ object TraktAuthRepository { ) persist() publish( - statusMessage = localizedString(Res.string.trakt_complete_sign_in_browser), + statusMessage = getString(Res.string.trakt_complete_sign_in_browser), errorMessage = null, ) @@ -187,7 +185,7 @@ object TraktAuthRepository { persist() publish( isLoading = false, - errorMessage = localizedString(Res.string.trakt_invalid_callback), + errorMessage = getString(Res.string.trakt_invalid_callback), ) return } @@ -195,7 +193,7 @@ object TraktAuthRepository { val errorCode = parsedUrl.parameters["error"] if (!errorCode.isNullOrBlank()) { val errorDescription = parsedUrl.parameters["error_description"] - ?: localizedString(Res.string.trakt_authorization_denied) + ?: getString(Res.string.trakt_authorization_denied) clearPendingAuthorization() persist() publish( @@ -211,7 +209,7 @@ object TraktAuthRepository { persist() publish( isLoading = false, - errorMessage = localizedString(Res.string.trakt_missing_auth_code), + errorMessage = getString(Res.string.trakt_missing_auth_code), ) return } @@ -223,7 +221,7 @@ object TraktAuthRepository { persist() publish( isLoading = false, - errorMessage = localizedString(Res.string.trakt_invalid_callback_state), + errorMessage = getString(Res.string.trakt_invalid_callback_state), ) return } @@ -255,7 +253,7 @@ object TraktAuthRepository { if (response == null) { clearPendingAuthorization() persist() - publish(isLoading = false, errorMessage = localizedString(Res.string.trakt_sign_in_complete_failed)) + publish(isLoading = false, errorMessage = getString(Res.string.trakt_sign_in_complete_failed)) return } @@ -266,7 +264,7 @@ object TraktAuthRepository { if (parsed == null) { clearPendingAuthorization() persist() - publish(isLoading = false, errorMessage = localizedString(Res.string.trakt_invalid_token_response)) + publish(isLoading = false, errorMessage = getString(Res.string.trakt_invalid_token_response)) return } @@ -494,4 +492,3 @@ private data class TraktUserDto( private data class TraktUserIdsDto( val slug: String? = null, ) - private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt index a2bd8a03..7580aa89 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt @@ -4,7 +4,6 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.details.MetaDetails -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json @@ -100,7 +99,8 @@ object TraktCommentsRepository { val itemCount = response.headers["X-Pagination-Item-Count"]?.toIntOrNull() ?: response.headers["x-pagination-item-count"]?.toIntOrNull() ?: dtos.size - val selected = filterDisplayableComments(dtos).map(::toReviewModel) + val userFallback = getString(Res.string.trakt_user_fallback) + val selected = filterDisplayableComments(dtos).map { dto -> toReviewModel(dto, userFallback) } cacheMutex.withLock { val cached = cache[cacheKey] @@ -222,11 +222,11 @@ private fun stripInlineSpoilerMarkup(comment: String?): String { .trim() } -private fun toReviewModel(dto: TraktCommentDto): TraktCommentReview { +private fun toReviewModel(dto: TraktCommentDto, userFallback: String): TraktCommentReview { val authorDisplayName = dto.user?.name ?.takeIf { it.isNotBlank() } ?: dto.user?.username?.takeIf { it.isNotBlank() } - ?: runBlocking { getString(Res.string.trakt_user_fallback) } + ?: userFallback return TraktCommentReview( id = dto.id, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt index 7a30963e..db3cc461 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.details +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -7,14 +8,15 @@ import kotlin.test.assertFailsWith class MetaDetailsParserTest { @Test - fun `parse rejects null meta object without json object cast crash`() { + fun `parse rejects null meta object without json object cast crash`() = runBlocking { assertFailsWith { - MetaDetailsParser.parse("""{"meta":null}""") + runBlocking { MetaDetailsParser.parse("""{"meta":null}""") } } + Unit } @Test - fun `parse accepts bare meta object response`() { + fun `parse accepts bare meta object response`() = runBlocking { val result = MetaDetailsParser.parse( """ {