From e2868a206013cbd15b6263d3323aa0d80a7240fa Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:24:50 +0530 Subject: [PATCH] trakt comments init --- .../kotlin/com/nuvio/app/MainActivity.kt | 2 + .../trakt/TraktCommentsStorage.android.kt | 29 ++ .../app/features/details/MetaDetailsScreen.kt | 121 ++++++++ .../details/components/CommentDetailSheet.kt | 226 +++++++++++++++ .../components/DetailCommentsSection.kt | 272 ++++++++++++++++++ .../app/features/settings/SettingsScreen.kt | 13 + .../features/settings/TraktSettingsPage.kt | 21 ++ .../app/features/trakt/TraktCommentsModels.kt | 77 +++++ .../features/trakt/TraktCommentsRepository.kt | 245 ++++++++++++++++ .../features/trakt/TraktCommentsSettings.kt | 33 +++ .../features/trakt/TraktCommentsStorage.kt | 6 + .../trakt/TraktCommentsStorage.ios.kt | 22 ++ 12 files changed, 1067 insertions(+) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/CommentDetailSheet.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsModels.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsSettings.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 08816b209..1b98d0aef 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.android.kt new file mode 100644 index 000000000..663478679 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.android.kt @@ -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() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 7e9f3461d..aef8667ce 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -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(null) } + val commentsEnabled by remember { + TraktCommentsSettings.ensureLoaded() + TraktCommentsSettings.enabled + }.collectAsStateWithLifecycle() + var comments by remember(type, id) { mutableStateOf>(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(null) } + var selectedComment by remember(type, id) { mutableStateOf(null) } val detailsScope = rememberCoroutineScope() var showLibraryListPicker by remember(type, id) { mutableStateOf(false) } var pickerTabs by remember(type, id) { mutableStateOf>(emptyList()) } @@ -121,6 +137,32 @@ fun MetaDetailsScreen( var pickerPending by remember(type, id) { mutableStateOf(false) } var pickerError by remember(type, id) { mutableStateOf(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 }, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/CommentDetailSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/CommentDetailSheet.kt new file mode 100644 index 000000000..b4c7eb569 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/CommentDetailSheet.kt @@ -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, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt new file mode 100644 index 000000000..c689b68af --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt @@ -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, + 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)), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 2dc3f3a07..7ead9e0a8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -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, 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, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, @@ -462,6 +473,8 @@ private fun TabletSettingsScreen( SettingsPage.TraktAuthentication -> traktSettingsContent( isTablet = true, uiState = traktAuthUiState, + commentsEnabled = traktCommentsEnabled, + onCommentsEnabledChange = TraktCommentsSettings::setEnabled, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt index 1460efaa8..b57ce3714 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsModels.kt new file mode 100644 index 000000000..9d4b1f626 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsModels.kt @@ -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, + 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, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt new file mode 100644 index 000000000..403795fc4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt @@ -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>, + val pageCount: Int, + val itemCount: Int, + val updatedAtMs: Long, + ) + + private val cacheMutex = Mutex() + private val cache = mutableMapOf() + + 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>(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>(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): List { + 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() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsSettings.kt new file mode 100644 index 000000000..9dcb97db5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsSettings.kt @@ -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 = _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 + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.kt new file mode 100644 index 000000000..d6df09f07 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.trakt + +internal expect object TraktCommentsStorage { + fun loadEnabled(): Boolean? + fun saveEnabled(enabled: Boolean) +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.ios.kt new file mode 100644 index 000000000..36220c19d --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.ios.kt @@ -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)) + } +}