This commit is contained in:
Aniket Tuli 2026-05-16 01:43:18 -05:00 committed by GitHub
commit 0970d24205
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 220 additions and 224 deletions

View file

@ -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"
}
}

View file

@ -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<String?>(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 = {

View file

@ -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()),
)
}
}

View file

@ -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

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.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<MetaVideo> =
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<MetaVideo> {
val result = mutableListOf<MetaVideo>()
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<MetaTrailer> =
array("trailers").mapNotNull { element ->
val trailer = element as? JsonObject ?: return@mapNotNull null
private suspend fun JsonObject.trailers(): List<MetaTrailer> {
val result = mutableListOf<MetaTrailer>()
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<StreamItem> {
private suspend fun JsonObject.embeddedStreams(): List<StreamItem> {
val arr = this["streams"] as? JsonArray ?: return emptyList()
return arr.mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
val result = mutableListOf<StreamItem>()
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? =

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.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)
}

View file

@ -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,

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_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
@ -215,6 +213,8 @@ private fun DetailRatingsRow(
if (orderedRatings.isEmpty()) return
val audienceScoreLabel = stringResource(Res.string.rating_audience_score)
Row(
modifier = Modifier
.fillMaxWidth()
@ -228,7 +228,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))
@ -350,7 +350,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),

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.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<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.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<ManagedAddon>) {
suspend fun syncCatalogs(addons: List<ManagedAddon>) {
ensureLoaded()
definitions = buildHomeCatalogDefinitions(addons)
collectionDefinitions = buildCollectionDefinitions(CollectionRepository.collections.value)
@ -138,7 +137,7 @@ object HomeCatalogSettingsRepository {
persist()
}
fun syncCollections(collections: List<Collection>) {
suspend fun syncCollections(collections: List<Collection>) {
ensureLoaded()
collectionDefinitions = buildCollectionDefinitions(collections)
normalizePreferences()
@ -530,13 +529,19 @@ internal data class CollectionCatalogDefinition(
val isPinnedToTop: Boolean,
)
internal fun buildCollectionDefinitions(collections: List<Collection>): List<CollectionCatalogDefinition> =
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<Collection>): List<CollectionCatalogDefinition> {
val result = mutableListOf<CollectionCatalogDefinition>()
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
}

View file

@ -238,7 +238,7 @@ private fun PlayerHeader(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = streamTitle,
text = streamTitle.ifBlank { stringResource(Res.string.stream_default_name) },
style = typeScale.labelSm.copy(
fontSize = metrics.metadataSize,
lineHeight = metrics.metadataSize * 1.25f,

View file

@ -629,7 +629,7 @@ private fun EpisodeSourceStreamRow(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stream.streamLabel,
text = stream.streamLabel.ifBlank { stringResource(Res.string.stream_default_name) },
color = colorScheme.onSurface,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,

View file

@ -267,7 +267,7 @@ private fun SourceStreamRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stream.streamLabel,
text = stream.streamLabel.ifBlank { stringResource(Res.string.stream_default_name) },
color = colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 14.sp,

View file

@ -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<ProfileState> = _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<CachedProfilePinPayload>(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))
}
}

View file

@ -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

View file

@ -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,
) {

View file

@ -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 title: String? = null,
@ -20,7 +16,7 @@ data class StreamItem(
val clientResolve: StreamClientResolve? = null,
) {
val streamLabel: String
get() = name ?: runBlocking { getString(Res.string.stream_default_name) }
get() = name.orEmpty()
val streamSubtitle: String?
get() = description

View file

@ -994,7 +994,7 @@ private fun StreamCard(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stream.streamLabel,
text = stream.streamLabel.ifBlank { stringResource(Res.string.stream_default_name) },
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
@ -1060,7 +1060,7 @@ private fun StreamActionsSheet(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stream.streamLabel,
text = stream.streamLabel.ifBlank { stringResource(Res.string.stream_default_name) },
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,

View file

@ -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<String, MutableList<MetaTrailer>>()
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<String> { 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<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") {
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,

View file

@ -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) }

View file

@ -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,

View file

@ -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
@ -9,12 +10,12 @@ class MetaDetailsParserTest {
@Test
fun `parse rejects null meta object without json object cast crash`() {
assertFailsWith<IllegalStateException> {
MetaDetailsParser.parse("""{"meta":null}""")
runBlocking { MetaDetailsParser.parse("""{"meta":null}""") }
}
}
@Test
fun `parse accepts bare meta object response`() {
fun `parse accepts bare meta object response`() = runBlocking {
val result = MetaDetailsParser.parse(
"""
{