refactor: drop runBlocking { getString } from common repos and parsers

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) <noreply@anthropic.com>
This commit is contained in:
Aniket Tuli 2026-05-13 11:06:55 -07:00
parent 37203d1fc1
commit 62d38f74f8
18 changed files with 217 additions and 220 deletions

View file

@ -1,10 +1,5 @@
package com.nuvio.app.features.addons 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( data class AddonManifest(
val id: String, val id: String,
val name: String, val name: String,
@ -60,7 +55,7 @@ data class ManagedAddon(
get() = userSetName?.takeIf { it.isNotBlank() && it != manifest?.name } get() = userSetName?.takeIf { it.isNotBlank() && it != manifest?.name }
?: manifest?.name ?: manifest?.name
?: manifestUrl.substringBefore("?").substringAfterLast("/").ifBlank { ?: manifestUrl.substringBefore("?").substringAfterLast("/").ifBlank {
runBlocking { getString(Res.string.generic_addon) } "Addon"
} }
} }

View file

@ -37,6 +37,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -57,6 +58,7 @@ import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.core.ui.NuvioSurfaceCard
import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import kotlinx.coroutines.launch
import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableCollectionItemScope
import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState import sh.calvin.reorderable.rememberReorderableLazyListState
@ -69,6 +71,7 @@ fun CollectionManagementScreen(
) { ) {
val collections by CollectionRepository.collections.collectAsState() val collections by CollectionRepository.collections.collectAsState()
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val coroutineScope = rememberCoroutineScope()
var showImportDialog by remember { mutableStateOf(false) } var showImportDialog by remember { mutableStateOf(false) }
var importText by remember { mutableStateOf("") } var importText by remember { mutableStateOf("") }
var importError by remember { mutableStateOf<String?>(null) } var importError by remember { mutableStateOf<String?>(null) }
@ -171,14 +174,16 @@ fun CollectionManagementScreen(
importError = null importError = null
}, },
onConfirm = { onConfirm = {
val result = CollectionRepository.validateJson(importText) coroutineScope.launch {
if (result.valid) { val result = CollectionRepository.validateJson(importText)
CollectionRepository.importFromJson(importText) if (result.valid) {
showImportDialog = false CollectionRepository.importFromJson(importText)
importText = "" showImportDialog = false
importError = null importText = ""
} else { importError = null
importError = result.error } else {
importError = result.error
}
} }
}, },
onDismiss = { onDismiss = {

View file

@ -9,7 +9,6 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -135,11 +134,11 @@ object CollectionRepository {
} }
} }
fun validateJson(jsonString: String): ValidationResult { suspend fun validateJson(jsonString: String): ValidationResult {
if (jsonString.isBlank()) { if (jsonString.isBlank()) {
return ValidationResult( return ValidationResult(
valid = false, valid = false,
error = runBlocking { getString(Res.string.collections_import_error_empty_json) }, error = getString(Res.string.collections_import_error_empty_json),
) )
} }
return try { return try {
@ -149,55 +148,45 @@ object CollectionRepository {
if (c.id.isBlank()) { if (c.id.isBlank()) {
return ValidationResult( return ValidationResult(
valid = false, valid = false,
error = runBlocking { error = getString(Res.string.collections_import_error_collection_blank_id, ci + 1),
getString(Res.string.collections_import_error_collection_blank_id, ci + 1)
},
) )
} }
if (c.title.isBlank()) { if (c.title.isBlank()) {
return ValidationResult( return ValidationResult(
valid = false, valid = false,
error = runBlocking { error = getString(Res.string.collections_import_error_collection_blank_title, c.id),
getString(Res.string.collections_import_error_collection_blank_title, c.id)
},
) )
} }
c.folders.forEachIndexed { fi, f -> c.folders.forEachIndexed { fi, f ->
if (f.id.isBlank()) { if (f.id.isBlank()) {
return ValidationResult( return ValidationResult(
valid = false, valid = false,
error = runBlocking { error = getString(
getString( Res.string.collections_import_error_folder_blank_id,
Res.string.collections_import_error_folder_blank_id, fi + 1,
fi + 1, c.title,
c.title, ),
)
},
) )
} }
if (f.title.isBlank()) { if (f.title.isBlank()) {
return ValidationResult( return ValidationResult(
valid = false, valid = false,
error = runBlocking { error = getString(
getString( Res.string.collections_import_error_folder_blank_title,
Res.string.collections_import_error_folder_blank_title, f.id,
f.id, c.title,
c.title, ),
)
},
) )
} }
f.resolvedSources.forEachIndexed { si, s -> f.resolvedSources.forEachIndexed { si, s ->
if (s.hasInvalidTraktListId()) { if (s.hasInvalidTraktListId()) {
return ValidationResult( return ValidationResult(
valid = false, valid = false,
error = runBlocking { error = getString(
getString( Res.string.collections_import_error_trakt_list_id,
Res.string.collections_import_error_trakt_list_id, si + 1,
si + 1, f.title,
f.title, ),
)
},
) )
} }
@ -208,13 +197,11 @@ object CollectionRepository {
if (invalidAddon || invalidTmdb) { if (invalidAddon || invalidTmdb) {
return ValidationResult( return ValidationResult(
valid = false, valid = false,
error = runBlocking { error = getString(
getString( Res.string.collections_import_error_source_blank_fields,
Res.string.collections_import_error_source_blank_fields, si + 1,
si + 1, f.title,
f.title, ),
)
},
) )
} }
} }
@ -229,9 +216,7 @@ object CollectionRepository {
} catch (e: Exception) { } catch (e: Exception) {
ValidationResult( ValidationResult(
valid = false, valid = false,
error = runBlocking { error = getString(Res.string.collections_import_error_invalid_json, e.message.orEmpty()),
getString(Res.string.collections_import_error_invalid_json, e.message.orEmpty())
},
) )
} }
} }

View file

@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.collections_folder_addon_not_found import nuvio.composeapp.generated.resources.collections_folder_addon_not_found
import nuvio.composeapp.generated.resources.collections_tab_all import nuvio.composeapp.generated.resources.collections_tab_all
@ -93,7 +92,7 @@ object FolderDetailRepository {
private var activeCollectionId: String? = null private var activeCollectionId: String? = null
private var activeFolderId: String? = null private var activeFolderId: String? = null
fun initialize(collectionId: String, folderId: String) { suspend fun initialize(collectionId: String, folderId: String) {
val current = _uiState.value val current = _uiState.value
if ( if (
activeCollectionId == collectionId && activeCollectionId == collectionId &&
@ -128,7 +127,7 @@ object FolderDetailRepository {
if (showAll) { if (showAll) {
add( add(
FolderTab( FolderTab(
label = runBlocking { getString(Res.string.collections_tab_all) }, label = getString(Res.string.collections_tab_all),
isAllTab = true, isAllTab = true,
isLoading = true, isLoading = true,
), ),
@ -213,12 +212,14 @@ object FolderDetailRepository {
val catalogSource = source.addonCatalogSource() val catalogSource = source.addonCatalogSource()
val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) } val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) }
if (!source.isTmdb && !source.isTrakt && resolvedCatalog == null) { if (!source.isTmdb && !source.isTrakt && resolvedCatalog == null) {
val errorMessage = getString(
Res.string.collections_folder_addon_not_found,
catalogSource?.addonId.orEmpty(),
)
updateTab(tabIndex) { updateTab(tabIndex) {
it.copy( it.copy(
isLoading = false, isLoading = false,
error = runBlocking { error = errorMessage,
getString(Res.string.collections_folder_addon_not_found, catalogSource?.addonId.orEmpty())
},
) )
} }
return@forEachIndexed return@forEachIndexed

View file

@ -3,7 +3,6 @@ package com.nuvio.app.features.details
import com.nuvio.app.features.streams.StreamBehaviorHints import com.nuvio.app.features.streams.StreamBehaviorHints
import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamProxyHeaders import com.nuvio.app.features.streams.StreamProxyHeaders
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
@ -20,7 +19,7 @@ import org.jetbrains.compose.resources.getString
internal object MetaDetailsParser { internal object MetaDetailsParser {
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
fun parse(payload: String): MetaDetails { suspend fun parse(payload: String): MetaDetails {
val root = json.parseToJsonElement(payload).asJsonObjectOrNull() val root = json.parseToJsonElement(payload).asJsonObjectOrNull()
?: error("Expected top-level JSON object in response") ?: error("Expected top-level JSON object in response")
val meta = root.extractMetaObject() val meta = root.extractMetaObject()
@ -217,59 +216,71 @@ internal object MetaDetailsParser {
return merged.values.toList() return merged.values.toList()
} }
private fun JsonObject.videos(): List<MetaVideo> = private suspend fun JsonObject.videos(): List<MetaVideo> {
array("videos").mapNotNull { element -> val result = mutableListOf<MetaVideo>()
val video = element as? JsonObject ?: return@mapNotNull null for (element in array("videos")) {
val id = video.string("id") ?: return@mapNotNull null val video = element as? JsonObject ?: continue
val title = video.string("title") ?: video.string("name") ?: return@mapNotNull null val id = video.string("id") ?: continue
MetaVideo( val title = video.string("title") ?: video.string("name") ?: continue
id = id, result.add(
title = title, MetaVideo(
released = video.string("released"), id = id,
thumbnail = video.string("thumbnail"), title = title,
seasonPoster = video.string("seasonPoster") ?: video.string("season_poster_path"), released = video.string("released"),
season = video.int("season"), thumbnail = video.string("thumbnail"),
episode = video.int("episode"), seasonPoster = video.string("seasonPoster") ?: video.string("season_poster_path"),
overview = video.string("overview") ?: video.string("description"), season = video.int("season"),
runtime = video.int("runtime"), episode = video.int("episode"),
streams = video.embeddedStreams(), overview = video.string("overview") ?: video.string("description"),
runtime = video.int("runtime"),
streams = video.embeddedStreams(),
),
) )
} }
return result
}
private fun JsonObject.trailers(): List<MetaTrailer> = private suspend fun JsonObject.trailers(): List<MetaTrailer> {
array("trailers").mapNotNull { element -> val result = mutableListOf<MetaTrailer>()
val trailer = element as? JsonObject ?: return@mapNotNull null for (element in array("trailers")) {
val trailer = element as? JsonObject ?: continue
val key = trailer.string("key") val key = trailer.string("key")
?: trailer.string("source") ?: trailer.string("source")
?: trailer.string("ytId") ?: trailer.string("ytId")
?: trailer.string("ytid") ?: trailer.string("ytid")
?: return@mapNotNull null ?: continue
val normalizedKey = key.trim() val normalizedKey = key.trim()
if (normalizedKey.isEmpty()) return@mapNotNull null if (normalizedKey.isEmpty()) continue
MetaTrailer( result.add(
id = trailer.string("id")?.takeIf(String::isNotBlank) ?: normalizedKey, MetaTrailer(
key = normalizedKey, id = trailer.string("id")?.takeIf(String::isNotBlank) ?: normalizedKey,
name = trailer.string("name")?.takeIf(String::isNotBlank) ?: runBlocking { getString(Res.string.generic_trailer) }, key = normalizedKey,
site = trailer.string("site")?.takeIf(String::isNotBlank) ?: "YouTube", name = trailer.string("name")?.takeIf(String::isNotBlank)
size = trailer.int("size"), ?: getString(Res.string.generic_trailer),
type = trailer.string("type")?.takeIf(String::isNotBlank) ?: runBlocking { getString(Res.string.generic_trailer) }, site = trailer.string("site")?.takeIf(String::isNotBlank) ?: "YouTube",
official = trailer.boolean("official") == true, size = trailer.int("size"),
publishedAt = trailer.string("published_at") ?: trailer.string("publishedAt"), type = trailer.string("type")?.takeIf(String::isNotBlank) ?: "",
seasonNumber = trailer.int("seasonNumber") ?: trailer.int("season_number"), official = trailer.boolean("official") == true,
displayName = trailer.string("displayName")?.takeIf(String::isNotBlank), 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<StreamItem> { private suspend fun JsonObject.embeddedStreams(): List<StreamItem> {
val arr = this["streams"] as? JsonArray ?: return emptyList() val arr = this["streams"] as? JsonArray ?: return emptyList()
return arr.mapNotNull { element -> val result = mutableListOf<StreamItem>()
val obj = element as? JsonObject ?: return@mapNotNull null for (element in arr) {
val obj = element as? JsonObject ?: continue
val url = obj.string("url") val url = obj.string("url")
val infoHash = obj.string("infoHash") val infoHash = obj.string("infoHash")
val externalUrl = obj.string("externalUrl") 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 hintsObj = obj["behaviorHints"] as? JsonObject
val proxyHeaders = hintsObj val proxyHeaders = hintsObj
@ -278,25 +289,28 @@ internal object MetaDetailsParser {
val streamData = obj["streamData"] as? JsonObject val streamData = obj["streamData"] as? JsonObject
val addonName = streamData?.string("addon") val addonName = streamData?.string("addon")
?: obj.string("name") ?: obj.string("name")
?: runBlocking { getString(Res.string.source_embedded) } ?: getString(Res.string.source_embedded)
StreamItem( result.add(
name = obj.string("name"), StreamItem(
description = obj.string("description") ?: obj.string("title"), name = obj.string("name"),
url = url, description = obj.string("description") ?: obj.string("title"),
infoHash = infoHash, url = url,
fileIdx = obj.int("fileIdx"), infoHash = infoHash,
externalUrl = externalUrl, fileIdx = obj.int("fileIdx"),
addonName = addonName, externalUrl = externalUrl,
addonId = "embedded", addonName = addonName,
behaviorHints = StreamBehaviorHints( addonId = "embedded",
bingeGroup = hintsObj?.string("bingeGroup"), behaviorHints = StreamBehaviorHints(
notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null, bingeGroup = hintsObj?.string("bingeGroup"),
videoSize = hintsObj?.long("videoSize"), notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null,
filename = hintsObj?.string("filename"), videoSize = hintsObj?.long("videoSize"),
proxyHeaders = proxyHeaders, filename = hintsObj?.string("filename"),
proxyHeaders = proxyHeaders,
),
), ),
) )
} }
return result
} }
private fun JsonObject.objectValue(name: String): JsonObject? = private fun JsonObject.objectValue(name: String): JsonObject? =

View file

@ -98,6 +98,7 @@ import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository 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.WatchingActions
import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -1176,7 +1177,7 @@ private fun ConfiguredMetaSections(
RenderSection(groupMembers.first().key) RenderSection(groupMembers.first().key)
} else { } else {
TabbedSectionGroup( TabbedSectionGroup(
tabs = groupMembers.map { it.key to it.title }, tabs = groupMembers.map { it.key to stringResource(it.key.titleRes) },
) { activeKey -> ) { activeKey ->
RenderSection(activeKey, showHeader = false) RenderSection(activeKey, showHeader = false)
} }

View file

@ -3,7 +3,6 @@ package com.nuvio.app.features.details
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
@ -11,7 +10,6 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
enum class MetaScreenSectionKey { enum class MetaScreenSectionKey {
ACTIONS, ACTIONS,
@ -33,8 +31,6 @@ enum class MetaScreenSectionKey {
data class MetaScreenSectionItem( data class MetaScreenSectionItem(
val key: MetaScreenSectionKey, val key: MetaScreenSectionKey,
val title: String,
val description: String,
val enabled: Boolean, val enabled: Boolean,
val order: Int, val order: Int,
val tabGroup: Int? = null, val tabGroup: Int? = null,
@ -160,7 +156,6 @@ object MetaScreenSettingsRepository {
private var tabLayout: Boolean = false private var tabLayout: Boolean = false
private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal
private var blurUnwatchedEpisodes: Boolean = false private var blurUnwatchedEpisodes: Boolean = false
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
fun ensureLoaded() { fun ensureLoaded() {
if (hasLoaded) return if (hasLoaded) return
@ -345,8 +340,6 @@ object MetaScreenSettingsRepository {
val preference = preferences[definition.key] val preference = preferences[definition.key]
MetaScreenSectionItem( MetaScreenSectionItem(
key = definition.key, key = definition.key,
title = localizedString(definition.titleRes),
description = localizedString(definition.descriptionRes),
enabled = preference?.enabled ?: true, enabled = preference?.enabled ?: true,
order = preference?.order ?: 0, order = preference?.order ?: 0,
tabGroup = preference?.tabGroup, tabGroup = preference?.tabGroup,

View file

@ -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_tmdb
import nuvio.composeapp.generated.resources.rating_trakt import nuvio.composeapp.generated.resources.rating_trakt
import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import kotlinx.coroutines.runBlocking
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -213,6 +211,8 @@ private fun DetailRatingsRow(
if (orderedRatings.isEmpty()) return if (orderedRatings.isEmpty()) return
val audienceScoreLabel = stringResource(Res.string.rating_audience_score)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -226,7 +226,7 @@ private fun DetailRatingsRow(
) { ) {
Image( Image(
painter = painterResource(visuals.logo), 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), modifier = Modifier.size(width = visuals.logoWidth, height = 16.dp),
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
@ -348,7 +348,7 @@ private val ratingVisuals = listOf(
), ),
RatingVisuals( RatingVisuals(
source = PROVIDER_AUDIENCE, source = PROVIDER_AUDIENCE,
displayName = runBlocking { getString(Res.string.rating_audience_score) }, displayName = "",
logo = Res.drawable.rating_audience_score, logo = Res.drawable.rating_audience_score,
logoWidth = 16.dp, logoWidth = 16.dp,
valueColor = Color(0xFFFA320A), valueColor = Color(0xFFFA320A),

View file

@ -76,9 +76,7 @@ import com.nuvio.app.features.details.seasonSortKey
import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
@ -1306,18 +1304,20 @@ private fun seriesContentSizing(maxWidthDp: Float): SeriesContentSizing =
) )
} }
@Composable
private fun Int.label(): String = private fun Int.label(): String =
if (this <= 0) { if (this <= 0) {
runBlocking { getString(Res.string.episodes_specials) } stringResource(Res.string.episodes_specials)
} else { } else {
runBlocking { getString(Res.string.episodes_season, this@label) } stringResource(Res.string.episodes_season, this@label)
} }
@Composable
private fun MetaVideo.episodeBadge(): String = private fun MetaVideo.episodeBadge(): String =
when { when {
episode != null || season != null -> episode != null || season != null ->
localizedSeasonEpisodeCode(seasonNumber = season, episodeNumber = episode).orEmpty() 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<Int, Int>? { private fun MetaVideo.seasonEpisodeKey(): Pair<Int, Int>? {

View file

@ -3,7 +3,6 @@ package com.nuvio.app.features.home
import com.nuvio.app.features.addons.ManagedAddon import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.collection.Collection import com.nuvio.app.features.collection.Collection
import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.collection.CollectionRepository
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -124,7 +123,7 @@ object HomeCatalogSettingsRepository {
_uiState.value = HomeCatalogSettingsUiState() _uiState.value = HomeCatalogSettingsUiState()
} }
fun syncCatalogs(addons: List<ManagedAddon>) { suspend fun syncCatalogs(addons: List<ManagedAddon>) {
ensureLoaded() ensureLoaded()
definitions = buildHomeCatalogDefinitions(addons) definitions = buildHomeCatalogDefinitions(addons)
collectionDefinitions = buildCollectionDefinitions(CollectionRepository.collections.value) collectionDefinitions = buildCollectionDefinitions(CollectionRepository.collections.value)
@ -138,7 +137,7 @@ object HomeCatalogSettingsRepository {
persist() persist()
} }
fun syncCollections(collections: List<Collection>) { suspend fun syncCollections(collections: List<Collection>) {
ensureLoaded() ensureLoaded()
collectionDefinitions = buildCollectionDefinitions(collections) collectionDefinitions = buildCollectionDefinitions(collections)
normalizePreferences() normalizePreferences()
@ -530,13 +529,19 @@ internal data class CollectionCatalogDefinition(
val isPinnedToTop: Boolean, val isPinnedToTop: Boolean,
) )
internal fun buildCollectionDefinitions(collections: List<Collection>): List<CollectionCatalogDefinition> = internal suspend fun buildCollectionDefinitions(collections: List<Collection>): List<CollectionCatalogDefinition> {
collections.filter { it.folders.isNotEmpty() }.map { collection -> val result = mutableListOf<CollectionCatalogDefinition>()
CollectionCatalogDefinition( for (collection in collections) {
key = "collection_${collection.id}", if (collection.folders.isEmpty()) continue
collectionId = collection.id, result.add(
title = collection.title, CollectionCatalogDefinition(
subtitle = runBlocking { getString(Res.string.collections_folder_count, collection.folders.size) }, key = "collection_${collection.id}",
isPinnedToTop = collection.pinToTop, collectionId = collection.id,
title = collection.title,
subtitle = getString(Res.string.collections_folder_count, collection.folders.size),
isPinnedToTop = collection.pinToTop,
),
) )
} }
return result
}

View file

@ -35,7 +35,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@ -44,7 +43,6 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
@Serializable @Serializable
@ -58,7 +56,6 @@ object ProfileRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val log = Logger.withTag("ProfileRepository") private val log = Logger.withTag("ProfileRepository")
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
private val _state = MutableStateFlow(ProfileState()) private val _state = MutableStateFlow(ProfileState())
val state: StateFlow<ProfileState> = _state.asStateFlow() val state: StateFlow<ProfileState> = _state.asStateFlow()
@ -414,7 +411,7 @@ object ProfileRepository {
ProfilePinCacheStorage.savePayload(profileIndex, json.encodeToString(payload)) 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 } val profile = _state.value.profiles.find { it.profileIndex == profileIndex }
if (profile?.pinEnabled != true) { if (profile?.pinEnabled != true) {
return PinVerifyResult(unlocked = true) return PinVerifyResult(unlocked = true)
@ -424,7 +421,7 @@ object ProfileRepository {
if (payload.isEmpty()) { if (payload.isEmpty()) {
return PinVerifyResult( return PinVerifyResult(
unlocked = false, 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<CachedProfilePinPayload>(payload) json.decodeFromString<CachedProfilePinPayload>(payload)
}.getOrNull() ?: return PinVerifyResult( }.getOrNull() ?: return PinVerifyResult(
unlocked = false, unlocked = false,
message = localizedString(Res.string.profile_pin_offline_verification_requires_online), message = getString(Res.string.profile_pin_offline_verification_requires_online),
) )
if ( if (
@ -443,7 +440,7 @@ object ProfileRepository {
ProfilePinCacheStorage.removePayload(profileIndex) ProfilePinCacheStorage.removePayload(profileIndex)
return PinVerifyResult( return PinVerifyResult(
unlocked = false, 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) { return if (digest == cached.digest) {
PinVerifyResult(unlocked = true) PinVerifyResult(unlocked = true)
} else { } else {
PinVerifyResult(unlocked = false, message = localizedString(Res.string.pin_incorrect)) PinVerifyResult(unlocked = false, message = getString(Res.string.pin_incorrect))
} }
} }

View file

@ -487,7 +487,7 @@ private val MetaEpisodeCardStyle.descriptionRes: StringResource
MetaEpisodeCardStyle.List -> Res.string.settings_meta_episode_style_list_description MetaEpisodeCardStyle.List -> Res.string.settings_meta_episode_style_list_description
} }
private val MetaScreenSectionKey.titleRes: StringResource internal val MetaScreenSectionKey.titleRes: StringResource
get() = when (this) { get() = when (this) {
MetaScreenSectionKey.ACTIONS -> Res.string.settings_meta_actions MetaScreenSectionKey.ACTIONS -> Res.string.settings_meta_actions
MetaScreenSectionKey.OVERVIEW -> Res.string.settings_meta_overview MetaScreenSectionKey.OVERVIEW -> Res.string.settings_meta_overview

View file

@ -27,6 +27,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalUriHandler 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.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
import com.nuvio.app.features.trakt.traktBrandPainter import com.nuvio.app.features.trakt.traktBrandPainter
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel import nuvio.composeapp.generated.resources.action_cancel
import nuvio.composeapp.generated.resources.settings_playback_dialog_close import nuvio.composeapp.generated.resources.settings_playback_dialog_close
@ -581,6 +583,7 @@ private fun TraktConnectionCard(
uiState: TraktAuthUiState, uiState: TraktAuthUiState,
) { ) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val coroutineScope = rememberCoroutineScope()
val horizontalPadding = if (isTablet) 20.dp else 16.dp val horizontalPadding = if (isTablet) 20.dp else 16.dp
val verticalPadding = if (isTablet) 18.dp else 16.dp val verticalPadding = if (isTablet) 18.dp else 16.dp
val failedOpenBrowserMessage = stringResource(Res.string.settings_trakt_failed_open_browser) val failedOpenBrowserMessage = stringResource(Res.string.settings_trakt_failed_open_browser)
@ -641,15 +644,17 @@ private fun TraktConnectionCard(
) )
Button( Button(
onClick = { onClick = {
val authUrl = TraktAuthRepository.pendingAuthorizationUrl() coroutineScope.launch {
?: TraktAuthRepository.onConnectRequested() val authUrl = TraktAuthRepository.pendingAuthorizationUrl()
if (authUrl == null) return@Button ?: TraktAuthRepository.onConnectRequested()
runCatching { uriHandler.openUri(authUrl) } if (authUrl == null) return@launch
.onFailure { runCatching { uriHandler.openUri(authUrl) }
TraktAuthRepository.onAuthLaunchFailed( .onFailure {
it.message ?: failedOpenBrowserMessage, TraktAuthRepository.onAuthLaunchFailed(
) it.message ?: failedOpenBrowserMessage,
} )
}
}
}, },
enabled = !uiState.isLoading, enabled = !uiState.isLoading,
) { ) {
@ -675,13 +680,15 @@ private fun TraktConnectionCard(
) )
Button( Button(
onClick = { onClick = {
val authUrl = TraktAuthRepository.onConnectRequested() ?: return@Button coroutineScope.launch {
runCatching { uriHandler.openUri(authUrl) } val authUrl = TraktAuthRepository.onConnectRequested() ?: return@launch
.onFailure { runCatching { uriHandler.openUri(authUrl) }
TraktAuthRepository.onAuthLaunchFailed( .onFailure {
it.message ?: failedOpenBrowserMessage, TraktAuthRepository.onAuthLaunchFailed(
) it.message ?: failedOpenBrowserMessage,
} )
}
}
}, },
enabled = uiState.credentialsConfigured && !uiState.isLoading, enabled = uiState.credentialsConfigured && !uiState.isLoading,
) { ) {

View file

@ -1,9 +1,5 @@
package com.nuvio.app.features.streams package com.nuvio.app.features.streams
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
data class StreamItem( data class StreamItem(
val name: String? = null, val name: String? = null,
val description: String? = null, val description: String? = null,
@ -17,7 +13,7 @@ data class StreamItem(
val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(),
) { ) {
val streamLabel: String val streamLabel: String
get() = name ?: runBlocking { getString(Res.string.stream_default_name) } get() = name ?: "Stream"
val streamSubtitle: String? val streamSubtitle: String?
get() = description get() = description

View file

@ -14,7 +14,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -97,7 +96,7 @@ object TmdbMetadataService {
val detail = PersonDetail( val detail = PersonDetail(
tmdbId = person.id ?: personId, tmdbId = person.id ?: personId,
name = person.name ?: runBlocking { getString(Res.string.generic_unknown) }, name = person.name ?: getString(Res.string.generic_unknown),
biography = biography, biography = biography,
birthday = person.birthday?.takeIf { it.isNotBlank() }, birthday = person.birthday?.takeIf { it.isNotBlank() },
deathday = person.deathday?.takeIf { it.isNotBlank() }, deathday = person.deathday?.takeIf { it.isNotBlank() },
@ -327,7 +326,7 @@ object TmdbMetadataService {
header = header ?: TmdbEntityHeader( header = header ?: TmdbEntityHeader(
id = entityId, id = entityId,
kind = entityKind, 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, logo = null,
originCountry = null, originCountry = null,
secondaryLabel = null, secondaryLabel = null,
@ -442,7 +441,7 @@ object TmdbMetadataService {
kind = entityKind, kind = entityKind,
name = it.name?.takeIf { n -> n.isNotBlank() } name = it.name?.takeIf { n -> n.isNotBlank() }
?: fallbackName?.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"), logo = buildImageUrl(it.logoPath, "w500"),
originCountry = it.originCountry?.takeIf { c -> c.isNotBlank() }, originCountry = it.originCountry?.takeIf { c -> c.isNotBlank() },
secondaryLabel = it.headquarters?.takeIf { h -> h.isNotBlank() }, secondaryLabel = it.headquarters?.takeIf { h -> h.isNotBlank() },
@ -458,7 +457,7 @@ object TmdbMetadataService {
kind = entityKind, kind = entityKind,
name = it.name?.takeIf { n -> n.isNotBlank() } name = it.name?.takeIf { n -> n.isNotBlank() }
?: fallbackName?.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"), logo = buildImageUrl(it.logoPath, "w500"),
originCountry = it.originCountry?.takeIf { c -> c.isNotBlank() }, originCountry = it.originCountry?.takeIf { c -> c.isNotBlank() },
secondaryLabel = it.headquarters?.takeIf { h -> h.isNotBlank() }, secondaryLabel = it.headquarters?.takeIf { h -> h.isNotBlank() },
@ -1112,8 +1111,8 @@ object TmdbMetadataService {
endpoint = "$mediaType/$tmdbId/videos", endpoint = "$mediaType/$tmdbId/videos",
language = language, language = language,
) )
allVideos += primaryVideos.map { video -> for (video in primaryVideos) {
video.toMetaTrailer( allVideos += video.toMetaTrailer(
seasonNumber = null, seasonNumber = null,
displayName = video.name, displayName = video.name,
) )
@ -1137,23 +1136,22 @@ object TmdbMetadataService {
}.awaitAll() }.awaitAll()
} }
seasonVideos.forEach { (seasonNumber, videos) -> for ((seasonNumber, videos) in seasonVideos) {
allVideos += videos.map { video -> for (video in videos) {
video.toMetaTrailer( allVideos += video.toMetaTrailer(
seasonNumber = seasonNumber, seasonNumber = seasonNumber,
displayName = runBlocking { displayName = getString(
getString( Res.string.trailer_season_label,
Res.string.trailer_season_label, seasonNumber,
seasonNumber, video.name.orEmpty(),
video.name.orEmpty(), ),
)
},
) )
} }
} }
} }
} }
val genericTrailerLabel = getString(Res.string.generic_trailer)
val byCategory = linkedMapOf<String, MutableList<MetaTrailer>>() val byCategory = linkedMapOf<String, MutableList<MetaTrailer>>()
allVideos allVideos
.asSequence() .asSequence()
@ -1162,7 +1160,7 @@ object TmdbMetadataService {
} }
.forEach { trailer -> .forEach { trailer ->
byCategory.getOrPut( byCategory.getOrPut(
trailer.type.ifBlank { runBlocking { getString(Res.string.generic_trailer) } }, trailer.type.ifBlank { genericTrailerLabel },
) { mutableListOf() } ) { mutableListOf() }
.add(trailer) .add(trailer)
} }
@ -1184,10 +1182,7 @@ object TmdbMetadataService {
val sortedCategories = byCategory.keys.sortedWith( val sortedCategories = byCategory.keys.sortedWith(
compareBy<String> { category -> compareBy<String> { category ->
when { when {
category.equals( category.equals(genericTrailerLabel, ignoreCase = true) -> 0
runBlocking { getString(Res.string.generic_trailer) },
ignoreCase = true,
) -> 0
byCategory[category].orEmpty().any { it.official } -> 1 byCategory[category].orEmpty().any { it.official } -> 1
else -> 2 else -> 2
} }
@ -1300,17 +1295,21 @@ internal fun normalizeTmdbLanguage(language: String?): String {
} }
} }
private fun buildPeople( private suspend fun buildPeople(
details: TmdbDetailsResponse, details: TmdbDetailsResponse,
credits: TmdbCreditsResponse?, credits: TmdbCreditsResponse?,
mediaType: String, mediaType: String,
): List<MetaPerson> { ): List<MetaPerson> {
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") { val creators = if (mediaType == "tv") {
details.createdBy.mapNotNull { creator -> details.createdBy.mapNotNull { creator ->
val name = creator.name?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null val name = creator.name?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null
MetaPerson( MetaPerson(
name = name, name = name,
role = runBlocking { getString(Res.string.person_role_creator) }, role = creatorRole,
photo = buildImageUrl(creator.profilePath, "w500"), photo = buildImageUrl(creator.profilePath, "w500"),
tmdbId = creator.id, tmdbId = creator.id,
) )
@ -1325,7 +1324,7 @@ private fun buildPeople(
val name = crew.name?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null val name = crew.name?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null
MetaPerson( MetaPerson(
name = name, name = name,
role = runBlocking { getString(Res.string.person_role_director) }, role = directorRole,
photo = buildImageUrl(crew.profilePath, "w500"), photo = buildImageUrl(crew.profilePath, "w500"),
tmdbId = crew.id, tmdbId = crew.id,
) )
@ -1340,7 +1339,7 @@ private fun buildPeople(
val name = crew.name?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null val name = crew.name?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null
MetaPerson( MetaPerson(
name = name, name = name,
role = runBlocking { getString(Res.string.person_role_writer) }, role = writerRole,
photo = buildImageUrl(crew.profilePath, "w500"), photo = buildImageUrl(crew.profilePath, "w500"),
tmdbId = crew.id, tmdbId = crew.id,
) )
@ -1543,12 +1542,12 @@ private data class TmdbVideoResult(
@SerialName("published_at") val publishedAt: String? = null, @SerialName("published_at") val publishedAt: String? = null,
) )
private fun TmdbVideoResult.toMetaTrailer( private suspend fun TmdbVideoResult.toMetaTrailer(
seasonNumber: Int?, seasonNumber: Int?,
displayName: String?, displayName: String?,
): MetaTrailer { ): MetaTrailer {
val videoKey = key?.trim().orEmpty() 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 val trailerId = id?.trim().takeUnless { it.isNullOrBlank() } ?: videoKey
return MetaTrailer( return MetaTrailer(
id = trailerId, id = trailerId,
@ -1556,7 +1555,7 @@ private fun TmdbVideoResult.toMetaTrailer(
name = videoName, name = videoName,
site = site?.trim().takeUnless { it.isNullOrBlank() } ?: "YouTube", site = site?.trim().takeUnless { it.isNullOrBlank() } ?: "YouTube",
size = size, size = size,
type = type?.trim().takeUnless { it.isNullOrBlank() } ?: runBlocking { getString(Res.string.generic_trailer) }, type = type?.trim().takeUnless { it.isNullOrBlank() } ?: "",
official = official == true, official = official == true,
publishedAt = publishedAt, publishedAt = publishedAt,
seasonNumber = seasonNumber, seasonNumber = seasonNumber,

View file

@ -21,8 +21,6 @@ import kotlinx.serialization.json.Json
import kotlin.random.Random import kotlin.random.Random
import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.StringResource
import kotlinx.coroutines.runBlocking
object TraktAuthRepository { object TraktAuthRepository {
private const val BASE_URL = "https://api.trakt.tv" private const val BASE_URL = "https://api.trakt.tv"
@ -68,10 +66,10 @@ object TraktAuthRepository {
fun hasRequiredCredentials(): Boolean = fun hasRequiredCredentials(): Boolean =
TraktConfig.CLIENT_ID.isNotBlank() && TraktConfig.CLIENT_SECRET.isNotBlank() TraktConfig.CLIENT_ID.isNotBlank() && TraktConfig.CLIENT_SECRET.isNotBlank()
fun onConnectRequested(): String? { suspend fun onConnectRequested(): String? {
ensureLoaded() ensureLoaded()
if (!hasRequiredCredentials()) { if (!hasRequiredCredentials()) {
publish(errorMessage = localizedString(Res.string.trakt_missing_credentials)) publish(errorMessage = getString(Res.string.trakt_missing_credentials))
return null return null
} }
@ -82,7 +80,7 @@ object TraktAuthRepository {
) )
persist() persist()
publish( publish(
statusMessage = localizedString(Res.string.trakt_complete_sign_in_browser), statusMessage = getString(Res.string.trakt_complete_sign_in_browser),
errorMessage = null, errorMessage = null,
) )
@ -187,7 +185,7 @@ object TraktAuthRepository {
persist() persist()
publish( publish(
isLoading = false, isLoading = false,
errorMessage = localizedString(Res.string.trakt_invalid_callback), errorMessage = getString(Res.string.trakt_invalid_callback),
) )
return return
} }
@ -195,7 +193,7 @@ object TraktAuthRepository {
val errorCode = parsedUrl.parameters["error"] val errorCode = parsedUrl.parameters["error"]
if (!errorCode.isNullOrBlank()) { if (!errorCode.isNullOrBlank()) {
val errorDescription = parsedUrl.parameters["error_description"] val errorDescription = parsedUrl.parameters["error_description"]
?: localizedString(Res.string.trakt_authorization_denied) ?: getString(Res.string.trakt_authorization_denied)
clearPendingAuthorization() clearPendingAuthorization()
persist() persist()
publish( publish(
@ -211,7 +209,7 @@ object TraktAuthRepository {
persist() persist()
publish( publish(
isLoading = false, isLoading = false,
errorMessage = localizedString(Res.string.trakt_missing_auth_code), errorMessage = getString(Res.string.trakt_missing_auth_code),
) )
return return
} }
@ -223,7 +221,7 @@ object TraktAuthRepository {
persist() persist()
publish( publish(
isLoading = false, isLoading = false,
errorMessage = localizedString(Res.string.trakt_invalid_callback_state), errorMessage = getString(Res.string.trakt_invalid_callback_state),
) )
return return
} }
@ -255,7 +253,7 @@ object TraktAuthRepository {
if (response == null) { if (response == null) {
clearPendingAuthorization() clearPendingAuthorization()
persist() 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 return
} }
@ -266,7 +264,7 @@ object TraktAuthRepository {
if (parsed == null) { if (parsed == null) {
clearPendingAuthorization() clearPendingAuthorization()
persist() persist()
publish(isLoading = false, errorMessage = localizedString(Res.string.trakt_invalid_token_response)) publish(isLoading = false, errorMessage = getString(Res.string.trakt_invalid_token_response))
return return
} }
@ -494,4 +492,3 @@ private data class TraktUserDto(
private data class TraktUserIdsDto( private data class TraktUserIdsDto(
val slug: String? = null, val slug: String? = null,
) )
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }

View file

@ -4,7 +4,6 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpGetTextWithHeaders
import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.addons.httpRequestRaw
import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaDetails
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -100,7 +99,8 @@ object TraktCommentsRepository {
val itemCount = response.headers["X-Pagination-Item-Count"]?.toIntOrNull() val itemCount = response.headers["X-Pagination-Item-Count"]?.toIntOrNull()
?: response.headers["x-pagination-item-count"]?.toIntOrNull() ?: response.headers["x-pagination-item-count"]?.toIntOrNull()
?: dtos.size ?: 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 { cacheMutex.withLock {
val cached = cache[cacheKey] val cached = cache[cacheKey]
@ -222,11 +222,11 @@ private fun stripInlineSpoilerMarkup(comment: String?): String {
.trim() .trim()
} }
private fun toReviewModel(dto: TraktCommentDto): TraktCommentReview { private fun toReviewModel(dto: TraktCommentDto, userFallback: String): TraktCommentReview {
val authorDisplayName = dto.user?.name val authorDisplayName = dto.user?.name
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?: dto.user?.username?.takeIf { it.isNotBlank() } ?: dto.user?.username?.takeIf { it.isNotBlank() }
?: runBlocking { getString(Res.string.trakt_user_fallback) } ?: userFallback
return TraktCommentReview( return TraktCommentReview(
id = dto.id, id = dto.id,

View file

@ -1,5 +1,6 @@
package com.nuvio.app.features.details package com.nuvio.app.features.details
import kotlinx.coroutines.runBlocking
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
@ -7,14 +8,15 @@ import kotlin.test.assertFailsWith
class MetaDetailsParserTest { class MetaDetailsParserTest {
@Test @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<IllegalStateException> { assertFailsWith<IllegalStateException> {
MetaDetailsParser.parse("""{"meta":null}""") runBlocking { MetaDetailsParser.parse("""{"meta":null}""") }
} }
Unit
} }
@Test @Test
fun `parse accepts bare meta object response`() { fun `parse accepts bare meta object response`() = runBlocking {
val result = MetaDetailsParser.parse( val result = MetaDetailsParser.parse(
""" """
{ {