diff --git a/src/components/metadata/CommentsSection.tsx b/src/components/metadata/CommentsSection.tsx
new file mode 100644
index 0000000..d94b856
--- /dev/null
+++ b/src/components/metadata/CommentsSection.tsx
@@ -0,0 +1,913 @@
+import React, { useCallback, useState } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ ActivityIndicator,
+ TouchableOpacity,
+ FlatList,
+ Dimensions,
+ Modal,
+ Alert,
+} from 'react-native';
+import { MaterialIcons } from '@expo/vector-icons';
+import { useTheme } from '../../contexts/ThemeContext';
+import { TraktContentComment } from '../../services/traktService';
+import { logger } from '../../utils/logger';
+import { useTraktComments } from '../../hooks/useTraktComments';
+
+const { width } = Dimensions.get('window');
+
+interface CommentsSectionProps {
+ imdbId: string;
+ type: 'movie' | 'show';
+ season?: number;
+ episode?: number;
+}
+
+interface CommentItemProps {
+ comment: TraktContentComment;
+ theme: any;
+}
+
+// Compact comment card for horizontal scrolling
+const CompactCommentCard: React.FC<{
+ comment: TraktContentComment;
+ theme: any;
+ onPress: () => void;
+ isSpoilerRevealed: boolean;
+ onSpoilerPress: () => void;
+}> = ({ comment, theme, onPress, isSpoilerRevealed, onSpoilerPress }) => {
+ // Safety check - ensure comment data exists
+ if (!comment || !comment.comment) {
+ return null;
+ }
+
+ // Handle missing user data gracefully
+ const user = comment.user || {};
+ const username = user.name || user.username || 'Anonymous';
+
+ // Handle spoiler content
+ const hasSpoiler = comment.spoiler;
+ const shouldBlurContent = hasSpoiler && !isSpoilerRevealed;
+
+ const truncatedComment = comment.comment.length > 100
+ ? comment.comment.substring(0, 100) + '...'
+ : comment.comment;
+
+ // Format relative time
+ const formatRelativeTime = (dateString: string) => {
+ try {
+ const now = new Date();
+ const commentDate = new Date(dateString);
+ const diffMs = now.getTime() - commentDate.getTime();
+ const diffMins = Math.floor(diffMs / (1000 * 60));
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+
+ if (diffMins < 1) return 'now';
+ if (diffMins < 60) return `${diffMins}m ago`;
+ if (diffHours < 24) return `${diffHours}h ago`;
+ if (diffDays < 7) return `${diffDays}d ago`;
+
+ // For older dates, show month/day
+ return commentDate.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric'
+ });
+ } catch {
+ return '';
+ }
+ };
+
+ // Render stars for rating (convert 1-10 rating to 1-5 stars)
+ const renderCompactStars = (rating: number) => {
+ const stars = [];
+ const fullStars = Math.floor(rating / 2); // Convert 10-point scale to 5 stars
+ const hasHalfStar = rating % 2 >= 1;
+
+ // Add full stars
+ for (let i = 0; i < fullStars; i++) {
+ stars.push(
+
+ );
+ }
+
+ // Add half star if needed
+ if (hasHalfStar) {
+ stars.push(
+
+ );
+ }
+
+ // Add empty stars to make 5 total
+ const filledStars = fullStars + (hasHalfStar ? 1 : 0);
+ const emptyStars = 5 - filledStars;
+ for (let i = 0; i < emptyStars; i++) {
+ stars.push(
+
+ );
+ }
+
+ return stars;
+ };
+
+ const handlePress = () => {
+ if (hasSpoiler && !isSpoilerRevealed) {
+ onSpoilerPress();
+ } else {
+ onPress();
+ }
+ };
+
+ return (
+
+ {/* Header Section - Fixed at top */}
+
+
+ {username}
+
+ {user.vip && (
+
+ VIP
+
+ )}
+
+
+ {/* Rating - Show stars */}
+ {comment.user_stats?.rating && (
+
+ {renderCompactStars(comment.user_stats.rating)}
+
+ {comment.user_stats.rating}/10
+
+
+ )}
+
+ {/* Comment Preview - Flexible area that fills space */}
+
+
+ {shouldBlurContent ? '⚠️ This comment contains spoilers. Tap to reveal.' : truncatedComment}
+
+
+
+ {/* Meta Info - Fixed at bottom */}
+
+
+ {comment.review && (
+
+ Review
+
+ )}
+ {comment.spoiler && (
+
+ Spoiler
+
+ )}
+
+
+
+ {formatRelativeTime(comment.created_at)}
+
+ {comment.likes > 0 && (
+
+ 👍 {comment.likes}
+
+ )}
+ {comment.replies > 0 && (
+
+ 💬 {comment.replies}
+
+ )}
+
+
+
+ );
+};
+
+// Expanded comment modal
+const ExpandedCommentModal: React.FC<{
+ comment: TraktContentComment | null;
+ visible: boolean;
+ onClose: () => void;
+ theme: any;
+ isSpoilerRevealed: boolean;
+ onSpoilerPress: () => void;
+}> = ({ comment, visible, onClose, theme, isSpoilerRevealed, onSpoilerPress }) => {
+ if (!comment) return null;
+
+ const user = comment.user || {};
+ const username = user.name || user.username || 'Anonymous User';
+ const hasSpoiler = comment.spoiler;
+ const shouldBlurModalContent = hasSpoiler && !isSpoilerRevealed;
+
+ const formatDate = (dateString: string) => {
+ try {
+ const date = new Date(dateString);
+ return date.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ } catch {
+ return 'Unknown date';
+ }
+ };
+
+ const renderStars = (rating: number | null) => {
+ if (rating === null) return null;
+
+ const stars = [];
+ const fullStars = Math.floor(rating / 2);
+ const hasHalfStar = rating % 2 >= 1;
+
+ for (let i = 0; i < fullStars; i++) {
+ stars.push(
+
+ );
+ }
+
+ if (hasHalfStar) {
+ stars.push(
+
+ );
+ }
+
+ const emptyStars = 5 - Math.ceil(rating / 2);
+ for (let i = 0; i < emptyStars; i++) {
+ stars.push(
+
+ );
+ }
+
+ return stars;
+ };
+
+ return (
+
+
+ {}} // Prevent closing when clicking on modal content
+ >
+ {/* Close Button */}
+
+
+
+
+ {/* User Info */}
+
+
+
+ {username}
+
+ {user.vip && (
+
+ VIP
+
+ )}
+
+
+ {formatDate(comment.created_at)}
+
+
+
+ {/* Rating */}
+ {comment.user_stats?.rating && (
+
+ {renderStars(comment.user_stats.rating)}
+
+ {comment.user_stats.rating}/10
+
+
+ )}
+
+ {/* Full Comment */}
+ {shouldBlurModalContent ? (
+
+
+ ⚠️ This comment contains spoilers
+
+
+
+ Reveal Spoilers
+
+
+
+ ) : (
+
+ {comment.comment}
+
+ )}
+
+ {/* Comment Meta */}
+
+ {comment.review && (
+ Review
+ )}
+ {comment.spoiler && (
+ Spoiler
+ )}
+
+ {comment.likes > 0 && (
+
+
+
+ {comment.likes}
+
+
+ )}
+ {comment.replies > 0 && (
+
+
+
+ {comment.replies}
+
+
+ )}
+
+
+
+
+
+ );
+};
+
+export const CommentsSection: React.FC = ({
+ imdbId,
+ type,
+ season,
+ episode,
+}) => {
+ const { currentTheme } = useTheme();
+ const [selectedComment, setSelectedComment] = useState(null);
+ const [modalVisible, setModalVisible] = useState(false);
+ const [revealedSpoilers, setRevealedSpoilers] = useState>(new Set());
+
+ const {
+ comments,
+ loading,
+ error,
+ hasMore,
+ isAuthenticated,
+ loadMore,
+ refresh,
+ } = useTraktComments({
+ imdbId,
+ type: type === 'show' ? (season !== undefined && episode !== undefined ? 'episode' :
+ season !== undefined ? 'season' : 'show') : 'movie',
+ season,
+ episode,
+ enabled: true,
+ });
+
+ const handleCommentPress = useCallback((comment: TraktContentComment) => {
+ setSelectedComment(comment);
+ setModalVisible(true);
+ }, []);
+
+ const handleModalClose = useCallback(() => {
+ setModalVisible(false);
+ setSelectedComment(null);
+ }, []);
+
+ const handleSpoilerPress = useCallback((comment: TraktContentComment) => {
+ Alert.alert(
+ 'Spoiler Warning',
+ 'This comment contains spoilers. Are you sure you want to reveal it?',
+ [
+ {
+ text: 'Cancel',
+ style: 'cancel',
+ },
+ {
+ text: 'Reveal Spoilers',
+ style: 'destructive',
+ onPress: () => {
+ setRevealedSpoilers(prev => new Set([...prev, comment.id.toString()]));
+ },
+ },
+ ]
+ );
+ }, []);
+
+ const renderComment = useCallback(({ item }: { item: TraktContentComment }) => (
+ handleCommentPress(item)}
+ isSpoilerRevealed={revealedSpoilers.has(item.id.toString())}
+ onSpoilerPress={() => handleSpoilerPress(item)}
+ />
+ ), [currentTheme, handleCommentPress, revealedSpoilers, handleSpoilerPress]);
+
+ const renderEmpty = useCallback(() => {
+ if (loading) return null;
+
+ return (
+
+
+
+ {error ? 'Comments unavailable' : 'No comments yet'}
+
+
+ {error
+ ? 'This content may not be in Trakt\'s database yet'
+ : 'Be the first to comment on Trakt.tv'
+ }
+
+
+ );
+ }, [loading, error, currentTheme]);
+
+ // Don't show section if not authenticated
+ if (!isAuthenticated) {
+ return null;
+ }
+
+ return (
+
+
+
+ Trakt Comments
+
+
+
+ {error && (
+
+
+
+ {error}
+
+
+
+ Retry
+
+
+
+ )}
+
+ {loading && comments.length === 0 && (
+
+
+
+ Loading comments...
+
+
+ )}
+
+ {comments.length === 0 ? (
+ renderEmpty()
+ ) : (
+ item?.id?.toString() || Math.random().toString()}
+ renderItem={renderComment}
+ contentContainerStyle={styles.horizontalList}
+ onEndReached={() => {
+ if (hasMore && !loading) {
+ loadMore();
+ }
+ }}
+ onEndReachedThreshold={0.5}
+ ListFooterComponent={
+ hasMore ? (
+
+
+ {loading ? (
+
+ ) : (
+ <>
+
+ Load More
+
+
+ >
+ )}
+
+
+ ) : null
+ }
+ />
+ )}
+
+ {/* Expanded Comment Modal */}
+ selectedComment && handleSpoilerPress(selectedComment)}
+ />
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ padding: 16,
+ marginBottom: 24,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 16,
+ },
+ title: {
+ fontSize: 20,
+ fontWeight: '600',
+ flex: 1,
+ },
+ horizontalList: {
+ paddingRight: 16,
+ },
+ compactCard: {
+ width: 280,
+ height: 160,
+ padding: 12,
+ marginRight: 12,
+ borderRadius: 12,
+ borderWidth: 1,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 4,
+ elevation: 3,
+ flexDirection: 'column',
+ },
+ compactHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 8,
+ },
+ compactUsername: {
+ fontSize: 16,
+ fontWeight: '600',
+ flex: 1,
+ },
+ miniVipBadge: {
+ backgroundColor: '#FFD700',
+ paddingHorizontal: 4,
+ paddingVertical: 1,
+ borderRadius: 6,
+ marginLeft: 6,
+ },
+ miniVipText: {
+ fontSize: 9,
+ fontWeight: '700',
+ color: '#000',
+ },
+ compactRating: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 8,
+ gap: 2,
+ },
+ compactRatingText: {
+ fontSize: 14,
+ fontWeight: '600',
+ marginLeft: 4,
+ },
+ commentContainer: {
+ flex: 1,
+ justifyContent: 'flex-start',
+ marginBottom: 8,
+ },
+ compactComment: {
+ fontSize: 14,
+ lineHeight: 20,
+ },
+ blurredContent: {
+ opacity: 0.3,
+ backgroundColor: 'rgba(0, 0, 0, 0.1)',
+ padding: 8,
+ borderRadius: 4,
+ },
+ compactMeta: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ compactBadges: {
+ flexDirection: 'row',
+ gap: 4,
+ },
+ miniReviewBadgeContainer: {
+ paddingHorizontal: 6,
+ paddingVertical: 2,
+ borderRadius: 8,
+ },
+ miniSpoilerBadgeContainer: {
+ paddingHorizontal: 6,
+ paddingVertical: 2,
+ borderRadius: 8,
+ },
+ miniBadgeText: {
+ fontSize: 9,
+ fontWeight: '700',
+ color: '#FFFFFF',
+ },
+ compactStats: {
+ flexDirection: 'row',
+ gap: 8,
+ },
+ compactStat: {
+ fontSize: 12,
+ },
+ compactTime: {
+ fontSize: 11,
+ fontWeight: '500',
+ },
+ loadMoreContainer: {
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ },
+ commentItem: {
+ padding: 16,
+ marginBottom: 12,
+ borderRadius: 12,
+ borderWidth: 1,
+ },
+ commentHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 8,
+ },
+ userInfo: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ flex: 1,
+ },
+ username: {
+ fontSize: 16,
+ fontWeight: '600',
+ flex: 1,
+ },
+ vipBadge: {
+ backgroundColor: '#FFD700',
+ paddingHorizontal: 6,
+ paddingVertical: 2,
+ borderRadius: 10,
+ marginLeft: 8,
+ },
+ vipText: {
+ fontSize: 10,
+ fontWeight: '700',
+ color: '#000',
+ },
+ date: {
+ fontSize: 12,
+ },
+ contentTitle: {
+ fontSize: 14,
+ fontWeight: '500',
+ marginBottom: 4,
+ },
+ ratingContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 8,
+ },
+ ratingText: {
+ fontSize: 12,
+ marginLeft: 4,
+ },
+ commentText: {
+ fontSize: 15,
+ lineHeight: 22,
+ marginBottom: 12,
+ },
+ commentMeta: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ reviewBadge: {
+ fontSize: 12,
+ fontWeight: '600',
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ borderRadius: 12,
+ backgroundColor: 'rgba(33, 150, 243, 0.1)',
+ },
+ spoilerBadge: {
+ fontSize: 12,
+ fontWeight: '600',
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ borderRadius: 12,
+ backgroundColor: 'rgba(244, 67, 54, 0.1)',
+ marginLeft: 8,
+ },
+ metaRight: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ likesContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginRight: 12,
+ },
+ likesText: {
+ fontSize: 12,
+ marginLeft: 4,
+ },
+ repliesContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ repliesText: {
+ fontSize: 12,
+ marginLeft: 4,
+ },
+ loadMoreButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ borderRadius: 8,
+ marginTop: 8,
+ marginBottom: 16,
+ },
+ loadMoreText: {
+ fontSize: 14,
+ fontWeight: '600',
+ marginRight: 8,
+ },
+ emptyContainer: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: 40,
+ },
+ emptyText: {
+ fontSize: 16,
+ fontWeight: '500',
+ marginTop: 12,
+ },
+ emptySubtext: {
+ fontSize: 14,
+ marginTop: 4,
+ textAlign: 'center',
+ },
+ emptyList: {
+ flexGrow: 1,
+ justifyContent: 'center',
+ },
+ loadingContainer: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: 40,
+ },
+ loadingText: {
+ fontSize: 14,
+ marginTop: 12,
+ },
+ errorContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ padding: 12,
+ borderRadius: 8,
+ marginBottom: 16,
+ },
+ errorText: {
+ fontSize: 14,
+ marginLeft: 8,
+ flex: 1,
+ },
+ retryButton: {
+ borderWidth: 1,
+ paddingHorizontal: 12,
+ paddingVertical: 6,
+ borderRadius: 6,
+ marginLeft: 12,
+ },
+ retryButtonText: {
+ fontSize: 12,
+ fontWeight: '600',
+ },
+ modalOverlay: {
+ flex: 1,
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 20,
+ },
+ modalContent: {
+ width: '90%',
+ maxWidth: 400,
+ maxHeight: '80%',
+ borderRadius: 16,
+ padding: 20,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 10 },
+ shadowOpacity: 0.3,
+ shadowRadius: 10,
+ },
+ closeButton: {
+ position: 'absolute',
+ top: 12,
+ right: 12,
+ padding: 8,
+ borderRadius: 20,
+ backgroundColor: 'rgba(0, 0, 0, 0.1)',
+ },
+ modalHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'flex-start',
+ marginBottom: 16,
+ paddingTop: 20,
+ },
+ modalUsername: {
+ fontSize: 18,
+ fontWeight: '600',
+ flex: 1,
+ },
+ modalDate: {
+ fontSize: 12,
+ marginTop: 4,
+ },
+ modalRating: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 16,
+ },
+ modalRatingText: {
+ fontSize: 14,
+ fontWeight: '600',
+ marginLeft: 8,
+ },
+ modalComment: {
+ fontSize: 16,
+ lineHeight: 24,
+ marginBottom: 16,
+ },
+ spoilerContainer: {
+ alignItems: 'center',
+ paddingVertical: 20,
+ },
+ spoilerWarning: {
+ fontSize: 16,
+ fontWeight: '600',
+ textAlign: 'center',
+ marginBottom: 16,
+ },
+ revealButton: {
+ paddingHorizontal: 20,
+ paddingVertical: 12,
+ borderRadius: 8,
+ },
+ revealButtonText: {
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ modalMeta: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ modalStats: {
+ flexDirection: 'row',
+ gap: 12,
+ },
+});
diff --git a/src/hooks/useTraktComments.ts b/src/hooks/useTraktComments.ts
new file mode 100644
index 0000000..8a729c0
--- /dev/null
+++ b/src/hooks/useTraktComments.ts
@@ -0,0 +1,129 @@
+import { useState, useEffect, useCallback } from 'react';
+import { TraktService, TraktContentComment } from '../services/traktService';
+import { logger } from '../utils/logger';
+
+interface UseTraktCommentsProps {
+ imdbId: string;
+ tmdbId?: number;
+ type: 'movie' | 'show' | 'season' | 'episode';
+ season?: number;
+ episode?: number;
+ enabled?: boolean;
+}
+
+export const useTraktComments = ({
+ imdbId,
+ tmdbId,
+ type,
+ season,
+ episode,
+ enabled = true
+}: UseTraktCommentsProps) => {
+ const [comments, setComments] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [hasMore, setHasMore] = useState(false);
+ const [page, setPage] = useState(1);
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+
+ const COMMENTS_PER_PAGE = 10;
+
+ // Check authentication status
+ useEffect(() => {
+ const checkAuth = async () => {
+ try {
+ const traktService = TraktService.getInstance();
+ const authenticated = await traktService.isAuthenticated();
+ setIsAuthenticated(authenticated);
+ } catch (error) {
+ logger.error('[useTraktComments] Failed to check authentication:', error);
+ setIsAuthenticated(false);
+ }
+ };
+
+ if (enabled) {
+ checkAuth();
+ }
+ }, [enabled]);
+
+ const loadComments = useCallback(async (pageNum: number = 1, append: boolean = false) => {
+ if (!enabled || !imdbId || !isAuthenticated) {
+ return;
+ }
+
+ try {
+ setLoading(true);
+ setError(null);
+
+ const traktService = TraktService.getInstance();
+ let fetchedComments: TraktContentComment[] = [];
+
+ console.log(`[useTraktComments] Loading comments for ${type} - IMDb: ${imdbId}, TMDB: ${tmdbId}, page: ${pageNum}`);
+
+ switch (type) {
+ case 'movie':
+ fetchedComments = await traktService.getMovieComments(imdbId, tmdbId, pageNum, COMMENTS_PER_PAGE);
+ break;
+ case 'show':
+ fetchedComments = await traktService.getShowComments(imdbId, tmdbId, pageNum, COMMENTS_PER_PAGE);
+ break;
+ case 'season':
+ if (season !== undefined) {
+ fetchedComments = await traktService.getSeasonComments(imdbId, season, pageNum, COMMENTS_PER_PAGE);
+ }
+ break;
+ case 'episode':
+ if (season !== undefined && episode !== undefined) {
+ fetchedComments = await traktService.getEpisodeComments(imdbId, season, episode, pageNum, COMMENTS_PER_PAGE);
+ }
+ break;
+ }
+
+ // Check if there are more comments (basic heuristic: if we got the full page, there might be more)
+ setHasMore(fetchedComments.length === COMMENTS_PER_PAGE);
+
+ setComments(prevComments => {
+ if (append) {
+ const newComments = [...prevComments, ...fetchedComments];
+ console.log(`[useTraktComments] Appended ${fetchedComments.length} comments, total: ${newComments.length}`);
+ return newComments;
+ } else {
+ console.log(`[useTraktComments] Loaded ${fetchedComments.length} comments`);
+ return fetchedComments;
+ }
+ });
+
+ setPage(pageNum);
+ } catch (error) {
+ logger.error('[useTraktComments] Failed to load comments:', error);
+ setError(error instanceof Error ? error.message : 'Failed to load comments');
+ } finally {
+ setLoading(false);
+ }
+ }, [enabled, imdbId, tmdbId, type, season, episode, isAuthenticated]);
+
+ const loadMore = useCallback(() => {
+ if (!loading && hasMore && isAuthenticated) {
+ loadComments(page + 1, true);
+ }
+ }, [loading, hasMore, page, loadComments, isAuthenticated]);
+
+ const refresh = useCallback(() => {
+ loadComments(1, false);
+ }, [loadComments]);
+
+ // Initial load
+ useEffect(() => {
+ loadComments(1, false);
+ }, [loadComments]);
+
+ return {
+ comments,
+ loading,
+ error,
+ hasMore,
+ isAuthenticated,
+ loadMore,
+ refresh
+ };
+};
diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx
index 38d6325..65df88b 100644
--- a/src/screens/MetadataScreen.tsx
+++ b/src/screens/MetadataScreen.tsx
@@ -24,6 +24,7 @@ import { SeriesContent } from '../components/metadata/SeriesContent';
import { MovieContent } from '../components/metadata/MovieContent';
import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
import { RatingsSection } from '../components/metadata/RatingsSection';
+import { CommentsSection } from '../components/metadata/CommentsSection';
import { RouteParams, Episode } from '../types/metadata';
import Animated, {
useAnimatedStyle,
@@ -63,6 +64,7 @@ const MemoizedSeriesContent = memo(SeriesContent);
const MemoizedMovieContent = memo(MovieContent);
const MemoizedMoreLikeThisSection = memo(MoreLikeThisSection);
const MemoizedRatingsSection = memo(RatingsSection);
+const MemoizedCommentsSection = memo(CommentsSection);
const MemoizedCastDetailsModal = memo(CastDetailsModal);
const MetadataScreen: React.FC = () => {
@@ -672,9 +674,18 @@ const MetadataScreen: React.FC = () => {
/>
)}
+ {/* Comments Section - Lazy loaded */}
+ {shouldLoadSecondaryData && imdbId && (
+ 0 ? 'show' : 'movie'}
+ />
+ )}
+
{/* Recommendations Section with skeleton when loading - Lazy loaded */}
{type === 'movie' && shouldLoadSecondaryData && (
-
diff --git a/src/services/traktService.ts b/src/services/traktService.ts
index 5972014..49e4dc2 100644
--- a/src/services/traktService.ts
+++ b/src/services/traktService.ts
@@ -383,6 +383,177 @@ export interface TraktHistoryRemoveResponse {
};
}
+// Comment types
+export interface TraktComment {
+ id: number;
+ comment: string;
+ spoiler: boolean;
+ review: boolean;
+ parent_id: number;
+ created_at: string;
+ updated_at: string;
+ replies: number;
+ likes: number;
+ user_stats?: {
+ rating?: number | null;
+ play_count?: number;
+ completed_count?: number;
+ };
+ user: {
+ username: string;
+ private: boolean;
+ name?: string;
+ vip: boolean;
+ vip_ep: boolean;
+ ids: {
+ slug: string;
+ };
+ };
+}
+
+export interface TraktMovieComment {
+ type: 'movie';
+ movie: {
+ title: string;
+ year: number;
+ ids: {
+ trakt: number;
+ slug: string;
+ imdb: string;
+ tmdb: number;
+ };
+ };
+ comment: TraktComment;
+}
+
+export interface TraktShowComment {
+ type: 'show';
+ show: {
+ title: string;
+ year: number;
+ ids: {
+ trakt: number;
+ slug: string;
+ tvdb?: number;
+ imdb: string;
+ tmdb: number;
+ };
+ };
+ comment: TraktComment;
+}
+
+export interface TraktSeasonComment {
+ type: 'season';
+ season: {
+ number: number;
+ ids: {
+ trakt: number;
+ tvdb?: number;
+ tmdb?: number;
+ };
+ };
+ show: {
+ title: string;
+ year: number;
+ ids: {
+ trakt: number;
+ slug: string;
+ tvdb?: number;
+ imdb: string;
+ tmdb: number;
+ };
+ };
+ comment: TraktComment;
+}
+
+export interface TraktEpisodeComment {
+ type: 'episode';
+ episode: {
+ season: number;
+ number: number;
+ title: string;
+ ids: {
+ trakt: number;
+ tvdb?: number;
+ imdb?: string;
+ tmdb?: number;
+ };
+ };
+ show: {
+ title: string;
+ year: number;
+ ids: {
+ trakt: number;
+ slug: string;
+ tvdb?: number;
+ imdb: string;
+ tmdb: number;
+ };
+ };
+ comment: TraktComment;
+}
+
+export interface TraktListComment {
+ type: 'list';
+ list: {
+ name: string;
+ description?: string;
+ privacy: string;
+ share_link?: string;
+ display_numbers: boolean;
+ allow_comments: boolean;
+ updated_at: string;
+ item_count: number;
+ comment_count: number;
+ likes: number;
+ ids: {
+ trakt: number;
+ slug: string;
+ };
+ };
+ comment: TraktComment;
+}
+
+// Simplified comment type based on actual API response
+export interface TraktContentComment {
+ id: number;
+ comment: string;
+ spoiler: boolean;
+ review: boolean;
+ parent_id: number;
+ created_at: string;
+ updated_at: string;
+ replies: number;
+ likes: number;
+ language: string;
+ user_rating?: number;
+ user_stats?: {
+ rating?: number;
+ play_count?: number;
+ completed_count?: number;
+ };
+ user: {
+ username: string;
+ private: boolean;
+ deleted?: boolean;
+ name?: string;
+ vip: boolean;
+ vip_ep: boolean;
+ director?: boolean;
+ ids: {
+ slug: string;
+ };
+ };
+}
+
+// Keep the old types for backward compatibility if needed
+export type TraktContentCommentLegacy =
+ | TraktMovieComment
+ | TraktShowComment
+ | TraktSeasonComment
+ | TraktEpisodeComment
+ | TraktListComment;
+
export class TraktService {
private static instance: TraktService;
private accessToken: string | null = null;
@@ -1062,24 +1233,56 @@ export class TraktService {
/**
* Get trakt id from IMDb id
*/
- public async getTraktIdFromImdbId(imdbId: string, type: 'movies' | 'shows'): Promise {
+ public async getTraktIdFromImdbId(imdbId: string, type: 'movie' | 'show'): Promise {
try {
- const response = await fetch(`${TRAKT_API_URL}/search/${type}?id_type=imdb&id=${imdbId}`, {
- headers: {
- 'Content-Type': 'application/json',
- 'trakt-api-version': '2',
- 'trakt-api-key': TRAKT_CLIENT_ID
+ // Clean IMDb ID - remove 'tt' prefix if present
+ const cleanImdbId = imdbId.startsWith('tt') ? imdbId.substring(2) : imdbId;
+
+ logger.log(`[TraktService] Searching Trakt for ${type} with IMDb ID: ${cleanImdbId}`);
+
+ // Try multiple search approaches
+ const searchUrls = [
+ `${TRAKT_API_URL}/search/${type}?id_type=imdb&id=${cleanImdbId}`,
+ `${TRAKT_API_URL}/search/${type}?query=${encodeURIComponent(cleanImdbId)}&id_type=imdb`,
+ // Also try with the full tt-prefixed ID in case the API accepts it
+ `${TRAKT_API_URL}/search/${type}?id_type=imdb&id=tt${cleanImdbId}`
+ ];
+
+ for (const searchUrl of searchUrls) {
+ try {
+ logger.log(`[TraktService] Trying search URL: ${searchUrl}`);
+
+ const response = await fetch(searchUrl, {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'trakt-api-version': '2',
+ 'trakt-api-key': TRAKT_CLIENT_ID
+ }
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ logger.warn(`[TraktService] Search attempt failed (${response.status}): ${errorText}`);
+ continue; // Try next URL
+ }
+
+ const data = await response.json();
+ logger.log(`[TraktService] Search response data:`, data);
+
+ if (data && data.length > 0) {
+ const traktId = data[0][type]?.ids?.trakt;
+ if (traktId) {
+ logger.log(`[TraktService] Found Trakt ID: ${traktId} for IMDb ID: ${cleanImdbId}`);
+ return traktId;
+ }
+ }
+ } catch (urlError) {
+ logger.warn(`[TraktService] URL attempt failed:`, urlError);
+ continue;
}
- });
-
- if (!response.ok) {
- throw new Error(`Failed to get Trakt ID: ${response.status}`);
}
- const data = await response.json();
- if (data && data.length > 0) {
- return data[0][type.slice(0, -1)].ids.trakt;
- }
+ logger.warn(`[TraktService] No results found for IMDb ID: ${cleanImdbId} after trying all search methods`);
return null;
} catch (error) {
logger.error('[TraktService] Failed to get Trakt ID from IMDb ID:', error);
@@ -1092,7 +1295,7 @@ export class TraktService {
*/
public async addToWatchedMovies(imdbId: string, watchedAt: Date = new Date()): Promise {
try {
- const traktId = await this.getTraktIdFromImdbId(imdbId, 'movies');
+ const traktId = await this.getTraktIdFromImdbId(imdbId, 'movie');
if (!traktId) {
return false;
}
@@ -1124,7 +1327,7 @@ export class TraktService {
watchedAt: Date = new Date()
): Promise {
try {
- const traktId = await this.getTraktIdFromImdbId(imdbId, 'shows');
+ const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
if (!traktId) {
return false;
}
@@ -1165,7 +1368,7 @@ export class TraktService {
return false;
}
- const traktId = await this.getTraktIdFromImdbId(imdbId, 'movies');
+ const traktId = await this.getTraktIdFromImdbId(imdbId, 'movie');
if (!traktId) {
return false;
}
@@ -1191,7 +1394,7 @@ export class TraktService {
return false;
}
- const traktId = await this.getTraktIdFromImdbId(imdbId, 'shows');
+ const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
if (!traktId) {
return false;
}
@@ -2160,6 +2363,137 @@ export class TraktService {
}
}
+ /**
+ * Get trakt id from TMDB id (fallback method)
+ */
+ public async getTraktIdFromTmdbId(tmdbId: number, type: 'movie' | 'show'): Promise {
+ try {
+ logger.log(`[TraktService] Searching Trakt for ${type} with TMDB ID: ${tmdbId}`);
+
+ const response = await fetch(`${TRAKT_API_URL}/search/${type}?id_type=tmdb&id=${tmdbId}`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'trakt-api-version': '2',
+ 'trakt-api-key': TRAKT_CLIENT_ID
+ }
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ logger.warn(`[TraktService] TMDB search failed (${response.status}): ${errorText}`);
+ return null;
+ }
+
+ const data = await response.json();
+ logger.log(`[TraktService] TMDB search response:`, data);
+ if (data && data.length > 0) {
+ const traktId = data[0][type]?.ids?.trakt;
+ if (traktId) {
+ logger.log(`[TraktService] Found Trakt ID via TMDB: ${traktId} for TMDB ID: ${tmdbId}`);
+ return traktId;
+ }
+ }
+
+ logger.warn(`[TraktService] No TMDB results found for TMDB ID: ${tmdbId}`);
+ return null;
+ } catch (error) {
+ logger.error('[TraktService] Failed to get Trakt ID from TMDB ID:', error);
+ return null;
+ }
+ }
+
+ /**
+ * Get comments for a movie
+ */
+ public async getMovieComments(imdbId: string, tmdbId?: number, page: number = 1, limit: number = 10): Promise {
+ try {
+ let traktId = await this.getTraktIdFromImdbId(imdbId, 'movie');
+
+ // Fallback to TMDB ID if IMDb search failed
+ if (!traktId && tmdbId) {
+ logger.log(`[TraktService] IMDb search failed, trying TMDB ID: ${tmdbId}`);
+ traktId = await this.getTraktIdFromTmdbId(tmdbId, 'movie');
+ }
+
+ if (!traktId) {
+ logger.warn(`[TraktService] Could not find Trakt ID for movie with IMDb: ${imdbId}, TMDB: ${tmdbId}`);
+ return [];
+ }
+
+ const endpoint = `/movies/${traktId}/comments?page=${page}&limit=${limit}`;
+ const result = await this.apiRequest(endpoint, 'GET');
+ console.log(`[TraktService] Movie comments response:`, result);
+ return result;
+ } catch (error) {
+ logger.error('[TraktService] Failed to get movie comments:', error);
+ return [];
+ }
+ }
+
+ /**
+ * Get comments for a show
+ */
+ public async getShowComments(imdbId: string, tmdbId?: number, page: number = 1, limit: number = 10): Promise {
+ try {
+ let traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
+
+ // Fallback to TMDB ID if IMDb search failed
+ if (!traktId && tmdbId) {
+ logger.log(`[TraktService] IMDb search failed, trying TMDB ID: ${tmdbId}`);
+ traktId = await this.getTraktIdFromTmdbId(tmdbId, 'show');
+ }
+
+ if (!traktId) {
+ logger.warn(`[TraktService] Could not find Trakt ID for show with IMDb: ${imdbId}, TMDB: ${tmdbId}`);
+ return [];
+ }
+
+ const endpoint = `/shows/${traktId}/comments?page=${page}&limit=${limit}`;
+ const result = await this.apiRequest(endpoint, 'GET');
+ console.log(`[TraktService] Show comments response:`, result);
+ return result;
+ } catch (error) {
+ logger.error('[TraktService] Failed to get show comments:', error);
+ return [];
+ }
+ }
+
+ /**
+ * Get comments for a season
+ */
+ public async getSeasonComments(imdbId: string, season: number, page: number = 1, limit: number = 10): Promise {
+ try {
+ const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
+ if (!traktId) {
+ return [];
+ }
+
+ const endpoint = `/shows/${traktId}/seasons/${season}/comments?page=${page}&limit=${limit}`;
+ return this.apiRequest(endpoint, 'GET');
+ } catch (error) {
+ logger.error('[TraktService] Failed to get season comments:', error);
+ return [];
+ }
+ }
+
+ /**
+ * Get comments for an episode
+ */
+ public async getEpisodeComments(imdbId: string, season: number, episode: number, page: number = 1, limit: number = 10): Promise {
+ try {
+ const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
+ if (!traktId) {
+ return [];
+ }
+
+ const endpoint = `/shows/${traktId}/seasons/${season}/episodes/${episode}/comments?page=${page}&limit=${limit}`;
+ return this.apiRequest(endpoint, 'GET');
+ } catch (error) {
+ logger.error('[TraktService] Failed to get episode comments:', error);
+ return [];
+ }
+ }
+
/**
* Handle app state changes to reduce memory pressure
*/