mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
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:
parent
37203d1fc1
commit
62d38f74f8
18 changed files with 217 additions and 220 deletions
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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())
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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? =
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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>? {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue