trakt comments init

This commit is contained in:
tapframe 2026-04-02 23:24:50 +05:30
parent 511804baab
commit e2868a2060
12 changed files with 1067 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.trakt
internal expect object TraktCommentsStorage {
fun loadEnabled(): Boolean?
fun saveEnabled(enabled: Boolean)
}

View file

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