mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
trakt comments init
This commit is contained in:
parent
511804baab
commit
e2868a2060
12 changed files with 1067 additions and 0 deletions
|
|
@ -19,6 +19,7 @@ import com.nuvio.app.features.details.SeasonViewModeStorage
|
|||
import com.nuvio.app.features.search.SearchHistoryStorage
|
||||
import com.nuvio.app.features.settings.ThemeSettingsStorage
|
||||
import com.nuvio.app.features.trakt.TraktAuthStorage
|
||||
import com.nuvio.app.features.trakt.TraktCommentsStorage
|
||||
import com.nuvio.app.features.trakt.handleTraktAuthCallbackUrl
|
||||
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
|
||||
import com.nuvio.app.features.watched.WatchedStorage
|
||||
|
|
@ -50,6 +51,7 @@ class MainActivity : ComponentActivity() {
|
|||
TmdbSettingsStorage.initialize(applicationContext)
|
||||
MdbListSettingsStorage.initialize(applicationContext)
|
||||
TraktAuthStorage.initialize(applicationContext)
|
||||
TraktCommentsStorage.initialize(applicationContext)
|
||||
ContinueWatchingPreferencesStorage.initialize(applicationContext)
|
||||
WatchProgressStorage.initialize(applicationContext)
|
||||
StreamLinkCacheStorage.initialize(applicationContext)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
package com.nuvio.app.features.trakt
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
|
||||
internal actual object TraktCommentsStorage {
|
||||
private const val preferencesName = "nuvio_trakt_comments"
|
||||
private const val enabledKey = "comments_enabled"
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
actual fun loadEnabled(): Boolean? {
|
||||
val prefs = preferences ?: return null
|
||||
val key = ProfileScopedKey.of(enabledKey)
|
||||
return if (prefs.contains(key)) prefs.getBoolean(key, true) else null
|
||||
}
|
||||
|
||||
actual fun saveEnabled(enabled: Boolean) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putBoolean(ProfileScopedKey.of(enabledKey), enabled)
|
||||
?.apply()
|
||||
}
|
||||
}
|
||||
|
|
@ -54,8 +54,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||
import com.nuvio.app.core.ui.NuvioBackButton
|
||||
import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding
|
||||
import com.nuvio.app.features.details.components.DetailActionButtons
|
||||
import com.nuvio.app.features.details.components.CommentDetailSheet
|
||||
import com.nuvio.app.features.details.components.DetailAdditionalInfoSection
|
||||
import com.nuvio.app.features.details.components.DetailCastSection
|
||||
import com.nuvio.app.features.details.components.DetailCommentsSection
|
||||
import com.nuvio.app.features.details.components.DetailFloatingHeader
|
||||
import com.nuvio.app.features.details.components.DetailHero
|
||||
import com.nuvio.app.features.details.components.DetailMetaInfo
|
||||
|
|
@ -69,6 +71,9 @@ import com.nuvio.app.features.home.MetaPreview
|
|||
import com.nuvio.app.features.library.LibraryRepository
|
||||
import com.nuvio.app.features.library.toLibraryItem
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktCommentReview
|
||||
import com.nuvio.app.features.trakt.TraktCommentsRepository
|
||||
import com.nuvio.app.features.trakt.TraktCommentsSettings
|
||||
import com.nuvio.app.features.trakt.TraktConnectionMode
|
||||
import com.nuvio.app.features.trakt.TraktListTab
|
||||
import com.nuvio.app.features.trailer.TrailerPlaybackResolver
|
||||
|
|
@ -114,6 +119,17 @@ fun MetaDetailsScreen(
|
|||
val requestedMeta = uiState.meta?.takeIf { it.type == type && it.id == id }
|
||||
val needsFreshLoad = requestedMeta == null && !uiState.isLoading
|
||||
var selectedEpisodeForActions by remember(type, id) { mutableStateOf<MetaVideo?>(null) }
|
||||
val commentsEnabled by remember {
|
||||
TraktCommentsSettings.ensureLoaded()
|
||||
TraktCommentsSettings.enabled
|
||||
}.collectAsStateWithLifecycle()
|
||||
var comments by remember(type, id) { mutableStateOf<List<TraktCommentReview>>(emptyList()) }
|
||||
var commentsCurrentPage by remember(type, id) { mutableIntStateOf(0) }
|
||||
var commentsPageCount by remember(type, id) { mutableIntStateOf(0) }
|
||||
var isCommentsLoading by remember(type, id) { mutableStateOf(false) }
|
||||
var isCommentsLoadingMore by remember(type, id) { mutableStateOf(false) }
|
||||
var commentsError by remember(type, id) { mutableStateOf<String?>(null) }
|
||||
var selectedComment by remember(type, id) { mutableStateOf<TraktCommentReview?>(null) }
|
||||
val detailsScope = rememberCoroutineScope()
|
||||
var showLibraryListPicker by remember(type, id) { mutableStateOf(false) }
|
||||
var pickerTabs by remember(type, id) { mutableStateOf<List<TraktListTab>>(emptyList()) }
|
||||
|
|
@ -121,6 +137,32 @@ fun MetaDetailsScreen(
|
|||
var pickerPending by remember(type, id) { mutableStateOf(false) }
|
||||
var pickerError by remember(type, id) { mutableStateOf<String?>(null) }
|
||||
|
||||
val shouldShowComments = commentsEnabled &&
|
||||
traktAuthUiState.mode == TraktConnectionMode.CONNECTED &&
|
||||
requestedMeta != null &&
|
||||
requestedMeta.type.lowercase().let { it == "movie" || it == "series" || it == "show" || it == "tv" }
|
||||
|
||||
LaunchedEffect(requestedMeta?.id, shouldShowComments) {
|
||||
if (!shouldShowComments || requestedMeta == null) {
|
||||
comments = emptyList()
|
||||
commentsCurrentPage = 0
|
||||
commentsPageCount = 0
|
||||
commentsError = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
isCommentsLoading = true
|
||||
commentsError = null
|
||||
try {
|
||||
val result = TraktCommentsRepository.getCommentsPage(requestedMeta, page = 1)
|
||||
comments = result.items
|
||||
commentsCurrentPage = result.currentPage
|
||||
commentsPageCount = result.pageCount
|
||||
} catch (e: Exception) {
|
||||
commentsError = e.message ?: "Failed to load comments"
|
||||
}
|
||||
isCommentsLoading = false
|
||||
}
|
||||
|
||||
LaunchedEffect(type, id, needsFreshLoad) {
|
||||
if (!needsFreshLoad) {
|
||||
screenAlpha.snapTo(1f)
|
||||
|
|
@ -405,6 +447,47 @@ fun MetaDetailsScreen(
|
|||
|
||||
DetailCastSection(cast = meta.cast)
|
||||
|
||||
if (shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank())) {
|
||||
DetailCommentsSection(
|
||||
comments = comments,
|
||||
isLoading = isCommentsLoading,
|
||||
isLoadingMore = isCommentsLoadingMore,
|
||||
canLoadMore = commentsCurrentPage < commentsPageCount,
|
||||
error = commentsError,
|
||||
onRetry = {
|
||||
detailsScope.launch {
|
||||
isCommentsLoading = true
|
||||
commentsError = null
|
||||
try {
|
||||
val result = TraktCommentsRepository.getCommentsPage(meta, page = 1, forceRefresh = true)
|
||||
comments = result.items
|
||||
commentsCurrentPage = result.currentPage
|
||||
commentsPageCount = result.pageCount
|
||||
} catch (e: Exception) {
|
||||
commentsError = e.message ?: "Failed to load comments"
|
||||
}
|
||||
isCommentsLoading = false
|
||||
}
|
||||
},
|
||||
onLoadMore = {
|
||||
detailsScope.launch {
|
||||
isCommentsLoadingMore = true
|
||||
try {
|
||||
val nextPage = commentsCurrentPage + 1
|
||||
val result = TraktCommentsRepository.getCommentsPage(meta, page = nextPage)
|
||||
val existingIds = comments.map { it.id }.toSet()
|
||||
val newComments = result.items.filter { it.id !in existingIds }
|
||||
comments = comments + newComments
|
||||
commentsCurrentPage = result.currentPage
|
||||
commentsPageCount = result.pageCount
|
||||
} catch (_: Exception) { }
|
||||
isCommentsLoadingMore = false
|
||||
}
|
||||
},
|
||||
onCommentClick = { review -> selectedComment = review },
|
||||
)
|
||||
}
|
||||
|
||||
if (hasTrailersSection) {
|
||||
DetailTrailersSection(
|
||||
trailers = meta.trailers,
|
||||
|
|
@ -628,6 +711,44 @@ fun MetaDetailsScreen(
|
|||
}
|
||||
},
|
||||
)
|
||||
|
||||
selectedComment?.let { comment ->
|
||||
val commentIndex = comments.indexOfFirst { it.id == comment.id }.coerceAtLeast(0)
|
||||
CommentDetailSheet(
|
||||
comment = comment,
|
||||
currentIndex = commentIndex,
|
||||
totalCount = comments.size,
|
||||
canGoBack = commentIndex > 0,
|
||||
canGoForward = commentIndex < comments.size - 1,
|
||||
onPrevious = {
|
||||
if (commentIndex > 0) {
|
||||
selectedComment = comments[commentIndex - 1]
|
||||
}
|
||||
},
|
||||
onNext = {
|
||||
val nextIndex = commentIndex + 1
|
||||
if (nextIndex < comments.size) {
|
||||
selectedComment = comments[nextIndex]
|
||||
}
|
||||
if (nextIndex >= comments.size - 3 && commentsCurrentPage < commentsPageCount) {
|
||||
detailsScope.launch {
|
||||
isCommentsLoadingMore = true
|
||||
try {
|
||||
val nextPage = commentsCurrentPage + 1
|
||||
val result = TraktCommentsRepository.getCommentsPage(meta, page = nextPage)
|
||||
val existingIds = comments.map { it.id }.toSet()
|
||||
val newComments = result.items.filter { it.id !in existingIds }
|
||||
comments = comments + newComments
|
||||
commentsCurrentPage = result.currentPage
|
||||
commentsPageCount = result.pageCount
|
||||
} catch (_: Exception) { }
|
||||
isCommentsLoadingMore = false
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = { selectedComment = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
package com.nuvio.app.features.details.components
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.nuvio.app.features.trakt.TraktCommentReview
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CommentDetailSheet(
|
||||
comment: TraktCommentReview,
|
||||
currentIndex: Int,
|
||||
totalCount: Int,
|
||||
canGoBack: Boolean,
|
||||
canGoForward: Boolean,
|
||||
onPrevious: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(comment.id) {
|
||||
scrollState.scrollTo(0)
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize(
|
||||
animationSpec = spring(
|
||||
dampingRatio = 0.9f,
|
||||
stiffness = 500f,
|
||||
),
|
||||
)
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = comment.authorDisplayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
comment.authorUsername?.let { username ->
|
||||
Text(
|
||||
text = "@$username",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (canGoBack) MaterialTheme.colorScheme.surfaceVariant
|
||||
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
)
|
||||
.then(if (canGoBack) Modifier.clickable(onClick = onPrevious) else Modifier),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowLeft,
|
||||
contentDescription = "Previous",
|
||||
tint = if (canGoBack) MaterialTheme.colorScheme.onSurface
|
||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${currentIndex + 1} / $totalCount",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (canGoForward) MaterialTheme.colorScheme.surfaceVariant
|
||||
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
)
|
||||
.then(if (canGoForward) Modifier.clickable(onClick = onNext) else Modifier),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
contentDescription = "Next",
|
||||
tint = if (canGoForward) MaterialTheme.colorScheme.onSurface
|
||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
if (comment.review) {
|
||||
CommentDetailChip(text = "Review")
|
||||
}
|
||||
if (comment.hasSpoilerContent) {
|
||||
CommentDetailChip(text = "Spoiler")
|
||||
}
|
||||
comment.rating?.let { rating ->
|
||||
CommentDetailChip(text = "Rating $rating/10")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = false)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Text(
|
||||
text = if (comment.hasSpoilerContent) {
|
||||
"This comment contains spoilers and has been hidden."
|
||||
} else {
|
||||
comment.comment
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 24.sp),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = "${comment.likes} likes",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
comment.createdAt?.take(10)?.let { date ->
|
||||
Text(
|
||||
text = date,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommentDetailChip(text: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 5.dp),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
package com.nuvio.app.features.details.components
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.nuvio.app.features.trakt.TraktCommentReview
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@Composable
|
||||
fun DetailCommentsSection(
|
||||
comments: List<TraktCommentReview>,
|
||||
isLoading: Boolean,
|
||||
isLoadingMore: Boolean,
|
||||
canLoadMore: Boolean,
|
||||
error: String?,
|
||||
onRetry: () -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
onCommentClick: (TraktCommentReview) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(listState, comments.size, canLoadMore, isLoadingMore, isLoading, error) {
|
||||
if (isLoading || !error.isNullOrBlank()) return@LaunchedEffect
|
||||
snapshotFlow {
|
||||
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||
val totalItems = listState.layoutInfo.totalItemsCount
|
||||
canLoadMore && !isLoadingMore && totalItems > 0 && lastVisibleIndex >= totalItems - 3
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { shouldLoadMore ->
|
||||
if (shouldLoadMore) onLoadMore()
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
CommentsHeader()
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
when {
|
||||
isLoading -> {
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(3) {
|
||||
LoadingCommentCard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
!error.isNullOrBlank() -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(
|
||||
onClick = onRetry,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
comments.isEmpty() -> {
|
||||
Text(
|
||||
text = "No comments yet.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
state = listState,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(comments, key = { it.id }) { review ->
|
||||
CommentCard(
|
||||
review = review,
|
||||
onClick = { onCommentClick(review) },
|
||||
)
|
||||
}
|
||||
if (isLoadingMore) {
|
||||
item(key = "loading_more_comments") {
|
||||
LoadingCommentCard()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommentsHeader() {
|
||||
BoxWithConstraints {
|
||||
val isTablet = maxWidth >= 720.dp
|
||||
val titleSize = if (isTablet) 22.sp else 20.sp
|
||||
|
||||
Text(
|
||||
text = "Trakt Comments",
|
||||
style = MaterialTheme.typography.titleLarge.copy(
|
||||
fontSize = titleSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommentCard(
|
||||
review: TraktCommentReview,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bodyText = if (review.hasSpoilerContent) {
|
||||
"This comment contains spoilers."
|
||||
} else {
|
||||
review.comment
|
||||
}
|
||||
|
||||
BoxWithConstraints {
|
||||
val isTablet = maxWidth >= 720.dp
|
||||
val cardWidth = if (isTablet) 340.dp else 280.dp
|
||||
val cardHeight = if (isTablet) 210.dp else 190.dp
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.width(cardWidth)
|
||||
.height(cardHeight)
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
tonalElevation = 1.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = review.authorDisplayName,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
if (review.review) {
|
||||
CommentChip(text = "Review")
|
||||
}
|
||||
if (review.hasSpoilerContent) {
|
||||
CommentChip(text = "Spoiler")
|
||||
}
|
||||
review.rating?.let { rating ->
|
||||
CommentChip(text = "Rating $rating/10")
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = bodyText,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "${review.likes} likes",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommentChip(text: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingCommentCard() {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "shimmer")
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.15f,
|
||||
targetValue = 0.35f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(800, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "shimmer_alpha",
|
||||
)
|
||||
|
||||
BoxWithConstraints {
|
||||
val isTablet = maxWidth >= 720.dp
|
||||
val cardWidth = if (isTablet) 340.dp else 280.dp
|
||||
val cardHeight = if (isTablet) 210.dp else 190.dp
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(cardWidth)
|
||||
.height(cardHeight)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = alpha)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@ import com.nuvio.app.features.mdblist.MdbListSettingsRepository
|
|||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||
import com.nuvio.app.features.trakt.TraktAuthUiState
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktCommentsSettings
|
||||
import com.nuvio.app.features.tmdb.TmdbSettings
|
||||
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||
|
|
@ -87,6 +88,10 @@ fun SettingsScreen(
|
|||
TraktAuthRepository.ensureLoaded()
|
||||
TraktAuthRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val traktCommentsEnabled by remember {
|
||||
TraktCommentsSettings.ensureLoaded()
|
||||
TraktCommentsSettings.enabled
|
||||
}.collectAsStateWithLifecycle()
|
||||
val addonsUiState by remember {
|
||||
AddonRepository.initialize()
|
||||
AddonRepository.uiState
|
||||
|
|
@ -133,6 +138,7 @@ fun SettingsScreen(
|
|||
tmdbSettings = tmdbSettings,
|
||||
mdbListSettings = mdbListSettings,
|
||||
traktAuthUiState = traktAuthUiState,
|
||||
traktCommentsEnabled = traktCommentsEnabled,
|
||||
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
|
||||
homescreenItems = homescreenSettingsUiState.items,
|
||||
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
||||
|
|
@ -159,6 +165,7 @@ fun SettingsScreen(
|
|||
tmdbSettings = tmdbSettings,
|
||||
mdbListSettings = mdbListSettings,
|
||||
traktAuthUiState = traktAuthUiState,
|
||||
traktCommentsEnabled = traktCommentsEnabled,
|
||||
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
|
||||
homescreenItems = homescreenSettingsUiState.items,
|
||||
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
||||
|
|
@ -194,6 +201,7 @@ private fun MobileSettingsScreen(
|
|||
tmdbSettings: TmdbSettings,
|
||||
mdbListSettings: MdbListSettings,
|
||||
traktAuthUiState: TraktAuthUiState,
|
||||
traktCommentsEnabled: Boolean,
|
||||
homescreenHeroEnabled: Boolean,
|
||||
homescreenItems: List<HomeCatalogSettingsItem>,
|
||||
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
||||
|
|
@ -283,6 +291,8 @@ private fun MobileSettingsScreen(
|
|||
SettingsPage.TraktAuthentication -> traktSettingsContent(
|
||||
isTablet = false,
|
||||
uiState = traktAuthUiState,
|
||||
commentsEnabled = traktCommentsEnabled,
|
||||
onCommentsEnabledChange = TraktCommentsSettings::setEnabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -309,6 +319,7 @@ private fun TabletSettingsScreen(
|
|||
tmdbSettings: TmdbSettings,
|
||||
mdbListSettings: MdbListSettings,
|
||||
traktAuthUiState: TraktAuthUiState,
|
||||
traktCommentsEnabled: Boolean,
|
||||
homescreenHeroEnabled: Boolean,
|
||||
homescreenItems: List<HomeCatalogSettingsItem>,
|
||||
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
||||
|
|
@ -462,6 +473,8 @@ private fun TabletSettingsScreen(
|
|||
SettingsPage.TraktAuthentication -> traktSettingsContent(
|
||||
isTablet = true,
|
||||
uiState = traktAuthUiState,
|
||||
commentsEnabled = traktCommentsEnabled,
|
||||
onCommentsEnabledChange = TraktCommentsSettings::setEnabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import com.nuvio.app.features.trakt.traktBrandPainter
|
|||
internal fun LazyListScope.traktSettingsContent(
|
||||
isTablet: Boolean,
|
||||
uiState: TraktAuthUiState,
|
||||
commentsEnabled: Boolean,
|
||||
onCommentsEnabledChange: (Boolean) -> Unit,
|
||||
) {
|
||||
item {
|
||||
SettingsGroup(isTablet = isTablet) {
|
||||
|
|
@ -48,6 +50,25 @@ internal fun LazyListScope.traktSettingsContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.mode == TraktConnectionMode.CONNECTED) {
|
||||
item {
|
||||
SettingsSection(
|
||||
title = "FEATURES",
|
||||
isTablet = isTablet,
|
||||
) {
|
||||
SettingsGroup(isTablet = isTablet) {
|
||||
SettingsSwitchRow(
|
||||
title = "Comments",
|
||||
description = "Show Trakt comments on movie and show details",
|
||||
checked = commentsEnabled,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = onCommentsEnabledChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
package com.nuvio.app.features.trakt
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
data class TraktCommentReview(
|
||||
val id: Long,
|
||||
val authorDisplayName: String,
|
||||
val authorUsername: String? = null,
|
||||
val comment: String,
|
||||
val spoiler: Boolean = false,
|
||||
val containsInlineSpoilers: Boolean = false,
|
||||
val review: Boolean = false,
|
||||
val likes: Int = 0,
|
||||
val rating: Int? = null,
|
||||
val createdAt: String? = null,
|
||||
val updatedAt: String? = null,
|
||||
) {
|
||||
val hasSpoilerContent: Boolean get() = spoiler || containsInlineSpoilers
|
||||
}
|
||||
|
||||
data class TraktCommentsPage(
|
||||
val items: List<TraktCommentReview>,
|
||||
val currentPage: Int,
|
||||
val pageCount: Int,
|
||||
val itemCount: Int,
|
||||
)
|
||||
|
||||
internal enum class TraktCommentsType(val apiValue: String) {
|
||||
MOVIE("movie"),
|
||||
SHOW("show"),
|
||||
}
|
||||
|
||||
@Serializable
|
||||
internal data class TraktCommentDto(
|
||||
@SerialName("id") val id: Long = 0,
|
||||
@SerialName("comment") val comment: String? = null,
|
||||
@SerialName("spoiler") val spoiler: Boolean? = null,
|
||||
@SerialName("review") val review: Boolean? = null,
|
||||
@SerialName("likes") val likes: Int? = null,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
@SerialName("user_stats") val userStats: TraktCommentUserStatsDto? = null,
|
||||
@SerialName("user") val user: TraktCommentUserDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class TraktCommentUserDto(
|
||||
@SerialName("username") val username: String? = null,
|
||||
@SerialName("name") val name: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class TraktCommentUserStatsDto(
|
||||
@SerialName("rating") val rating: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class TraktCommentsSearchResultDto(
|
||||
@SerialName("type") val type: String? = null,
|
||||
@SerialName("score") val score: Double? = null,
|
||||
@SerialName("movie") val movie: TraktCommentsSearchItemDto? = null,
|
||||
@SerialName("show") val show: TraktCommentsSearchItemDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class TraktCommentsSearchItemDto(
|
||||
@SerialName("ids") val ids: TraktCommentsIdsDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class TraktCommentsIdsDto(
|
||||
@SerialName("trakt") val trakt: Long? = null,
|
||||
@SerialName("slug") val slug: String? = null,
|
||||
@SerialName("imdb") val imdb: String? = null,
|
||||
@SerialName("tmdb") val tmdb: Int? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
package com.nuvio.app.features.trakt
|
||||
|
||||
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 com.nuvio.app.features.plugins.currentEpochMillis
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
private const val COMMENTS_SORT = "likes"
|
||||
private const val COMMENTS_LIMIT = 100
|
||||
private const val COMMENTS_CACHE_TTL_MS = 10 * 60_000L
|
||||
private val INLINE_SPOILER_REGEX = Regex(
|
||||
"\\[spoiler\\].*?\\[/spoiler\\]",
|
||||
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL),
|
||||
)
|
||||
private val INLINE_SPOILER_TAG_REGEX = Regex("\\[/?spoiler\\]", RegexOption.IGNORE_CASE)
|
||||
|
||||
private val commentsJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
object TraktCommentsRepository {
|
||||
private val log = Logger.withTag("TraktComments")
|
||||
|
||||
private data class TimedCache(
|
||||
val pages: Map<Int, List<TraktCommentReview>>,
|
||||
val pageCount: Int,
|
||||
val itemCount: Int,
|
||||
val updatedAtMs: Long,
|
||||
)
|
||||
|
||||
private val cacheMutex = Mutex()
|
||||
private val cache = mutableMapOf<String, TimedCache>()
|
||||
|
||||
suspend fun getCommentsPage(
|
||||
meta: MetaDetails,
|
||||
page: Int = 1,
|
||||
forceRefresh: Boolean = false,
|
||||
): TraktCommentsPage {
|
||||
val target = resolveCommentsTarget(meta)
|
||||
?: return TraktCommentsPage(emptyList(), page, 0, 0)
|
||||
val cacheKey = "${target.type.apiValue}|${target.pathId}"
|
||||
|
||||
if (forceRefresh) {
|
||||
cacheMutex.withLock { cache.remove(cacheKey) }
|
||||
}
|
||||
|
||||
if (!forceRefresh) {
|
||||
cacheMutex.withLock {
|
||||
val cached = cache[cacheKey]
|
||||
if (
|
||||
cached != null &&
|
||||
currentTimeMillis() - cached.updatedAtMs <= COMMENTS_CACHE_TTL_MS &&
|
||||
cached.pages.containsKey(page)
|
||||
) {
|
||||
return TraktCommentsPage(
|
||||
items = cached.pages.getValue(page),
|
||||
currentPage = page,
|
||||
pageCount = cached.pageCount,
|
||||
itemCount = cached.itemCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val headers = TraktAuthRepository.authorizedHeaders()
|
||||
?: return TraktCommentsPage(emptyList(), page, 0, 0)
|
||||
|
||||
val endpoint = when (target.type) {
|
||||
TraktCommentsType.MOVIE -> "movies"
|
||||
TraktCommentsType.SHOW -> "shows"
|
||||
}
|
||||
val url = "https://api.trakt.tv/$endpoint/${target.pathId}/comments/$COMMENTS_SORT?page=$page&limit=$COMMENTS_LIMIT"
|
||||
|
||||
val response = try {
|
||||
httpRequestRaw(
|
||||
method = "GET",
|
||||
url = url,
|
||||
headers = headers,
|
||||
body = "",
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log.e(e) { "Failed to load comments from $url" }
|
||||
throw e
|
||||
}
|
||||
|
||||
if (response.status == 404) {
|
||||
return TraktCommentsPage(emptyList(), page, 0, 0)
|
||||
}
|
||||
if (response.status !in 200..299) {
|
||||
throw IllegalStateException("Failed to load Trakt comments (${response.status})")
|
||||
}
|
||||
|
||||
val dtos = commentsJson.decodeFromString<List<TraktCommentDto>>(response.body)
|
||||
val pageCount = response.headers["X-Pagination-Page-Count"]?.toIntOrNull()
|
||||
?: response.headers["x-pagination-page-count"]?.toIntOrNull()
|
||||
?: page
|
||||
val itemCount = response.headers["X-Pagination-Item-Count"]?.toIntOrNull()
|
||||
?: response.headers["x-pagination-item-count"]?.toIntOrNull()
|
||||
?: dtos.size
|
||||
val selected = filterDisplayableComments(dtos).map(::toReviewModel)
|
||||
|
||||
cacheMutex.withLock {
|
||||
val cached = cache[cacheKey]
|
||||
cache[cacheKey] = TimedCache(
|
||||
pages = (cached?.pages.orEmpty()) + (page to selected),
|
||||
pageCount = pageCount,
|
||||
itemCount = itemCount,
|
||||
updatedAtMs = currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
return TraktCommentsPage(
|
||||
items = selected,
|
||||
currentPage = page,
|
||||
pageCount = pageCount,
|
||||
itemCount = itemCount,
|
||||
)
|
||||
}
|
||||
|
||||
fun clearCache() {
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
private suspend fun resolveCommentsTarget(meta: MetaDetails): ResolvedCommentsTarget? {
|
||||
val type = resolveCommentsType(meta) ?: return null
|
||||
val directPathId = resolveDirectPathId(meta)
|
||||
if (!directPathId.isNullOrBlank()) {
|
||||
return ResolvedCommentsTarget(type, directPathId)
|
||||
}
|
||||
val tmdbId = resolveTmdbCandidate(meta) ?: return null
|
||||
return resolveViaTraktSearch(type, tmdbId)
|
||||
}
|
||||
|
||||
private fun resolveCommentsType(meta: MetaDetails): TraktCommentsType? {
|
||||
val normalized = meta.type.trim().lowercase()
|
||||
return when (normalized) {
|
||||
"movie" -> TraktCommentsType.MOVIE
|
||||
"series", "show", "tv" -> TraktCommentsType.SHOW
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveDirectPathId(meta: MetaDetails): String? {
|
||||
val id = meta.id.trim()
|
||||
if (id.startsWith("tt") && id.length >= 7) return id
|
||||
val parts = id.split(":")
|
||||
for (part in parts) {
|
||||
val trimmed = part.trim()
|
||||
if (trimmed.startsWith("tt") && trimmed.length >= 7) return trimmed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun resolveTmdbCandidate(meta: MetaDetails): Int? {
|
||||
val id = meta.id.trim()
|
||||
val parts = id.split(":")
|
||||
for (part in parts) {
|
||||
val trimmed = part.trim()
|
||||
if (!trimmed.startsWith("tt") && trimmed.all { it.isDigit() } && trimmed.isNotEmpty()) {
|
||||
return trimmed.toIntOrNull()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun resolveViaTraktSearch(
|
||||
type: TraktCommentsType,
|
||||
tmdbId: Int,
|
||||
): ResolvedCommentsTarget? {
|
||||
val headers = TraktAuthRepository.authorizedHeaders() ?: return null
|
||||
val url = "https://api.trakt.tv/search/tmdb/$tmdbId?type=${type.apiValue}"
|
||||
return try {
|
||||
val responseText = httpGetTextWithHeaders(url, headers)
|
||||
val results = commentsJson.decodeFromString<List<TraktCommentsSearchResultDto>>(responseText)
|
||||
val matched = results.firstOrNull {
|
||||
it.type.equals(type.apiValue, ignoreCase = true)
|
||||
}
|
||||
val ids = when (type) {
|
||||
TraktCommentsType.MOVIE -> matched?.movie?.ids
|
||||
TraktCommentsType.SHOW -> matched?.show?.ids
|
||||
}
|
||||
val pathId = ids?.bestPathId() ?: return null
|
||||
ResolvedCommentsTarget(type, pathId)
|
||||
} catch (e: Exception) {
|
||||
log.w(e) { "TMDB→Trakt lookup failed for tmdbId=$tmdbId" }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ResolvedCommentsTarget(
|
||||
val type: TraktCommentsType,
|
||||
val pathId: String,
|
||||
)
|
||||
|
||||
private fun TraktCommentsIdsDto.bestPathId(): String? {
|
||||
return when {
|
||||
!imdb.isNullOrBlank() -> imdb
|
||||
trakt != null -> trakt.toString()
|
||||
!slug.isNullOrBlank() -> slug
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterDisplayableComments(comments: List<TraktCommentDto>): List<TraktCommentDto> {
|
||||
return comments.filter { !it.comment.isNullOrBlank() }
|
||||
}
|
||||
|
||||
private fun containsInlineSpoilers(comment: String?): Boolean {
|
||||
if (comment.isNullOrBlank()) return false
|
||||
return INLINE_SPOILER_REGEX.containsMatchIn(comment)
|
||||
}
|
||||
|
||||
private fun stripInlineSpoilerMarkup(comment: String?): String {
|
||||
if (comment.isNullOrBlank()) return ""
|
||||
return comment
|
||||
.replace(INLINE_SPOILER_TAG_REGEX, "")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
private fun toReviewModel(dto: TraktCommentDto): TraktCommentReview {
|
||||
val authorDisplayName = dto.user?.name
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: dto.user?.username?.takeIf { it.isNotBlank() }
|
||||
?: "Trakt user"
|
||||
|
||||
return TraktCommentReview(
|
||||
id = dto.id,
|
||||
authorDisplayName = authorDisplayName,
|
||||
authorUsername = dto.user?.username?.takeIf { it.isNotBlank() },
|
||||
comment = stripInlineSpoilerMarkup(dto.comment),
|
||||
spoiler = dto.spoiler == true,
|
||||
containsInlineSpoilers = containsInlineSpoilers(dto.comment),
|
||||
review = dto.review == true,
|
||||
likes = dto.likes ?: 0,
|
||||
rating = dto.userStats?.rating,
|
||||
createdAt = dto.createdAt,
|
||||
updatedAt = dto.updatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
private fun currentTimeMillis(): Long = currentEpochMillis()
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.nuvio.app.features.trakt
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
object TraktCommentsSettings {
|
||||
private val _enabled = MutableStateFlow(true)
|
||||
val enabled: StateFlow<Boolean> = _enabled.asStateFlow()
|
||||
|
||||
private var hasLoaded = false
|
||||
|
||||
fun ensureLoaded() {
|
||||
if (hasLoaded) return
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
fun onProfileChanged() {
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
fun setEnabled(value: Boolean) {
|
||||
ensureLoaded()
|
||||
if (_enabled.value == value) return
|
||||
_enabled.value = value
|
||||
TraktCommentsStorage.saveEnabled(value)
|
||||
}
|
||||
|
||||
private fun loadFromDisk() {
|
||||
hasLoaded = true
|
||||
_enabled.value = TraktCommentsStorage.loadEnabled() ?: true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.nuvio.app.features.trakt
|
||||
|
||||
internal expect object TraktCommentsStorage {
|
||||
fun loadEnabled(): Boolean?
|
||||
fun saveEnabled(enabled: Boolean)
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.nuvio.app.features.trakt
|
||||
|
||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
import platform.Foundation.NSUserDefaults
|
||||
|
||||
internal actual object TraktCommentsStorage {
|
||||
private const val enabledKey = "comments_enabled"
|
||||
|
||||
actual fun loadEnabled(): Boolean? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val key = ProfileScopedKey.of(enabledKey)
|
||||
return if (defaults.objectForKey(key) != null) {
|
||||
defaults.boolForKey(key)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveEnabled(enabled: Boolean) {
|
||||
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(enabledKey))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue