trakt comment added

This commit is contained in:
tapframe 2025-10-05 18:40:06 +05:30
parent ca52a81141
commit cc2e0308d7
4 changed files with 1406 additions and 19 deletions

View file

@ -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(
<MaterialIcons key={`full-${i}`} name="star" size={10} color="#FFD700" />
);
}
// Add half star if needed
if (hasHalfStar) {
stars.push(
<MaterialIcons key="half" name="star-half" size={10} color="#FFD700" />
);
}
// 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(
<MaterialIcons key={`empty-${i}`} name="star-border" size={10} color="#FFD700" />
);
}
return stars;
};
const handlePress = () => {
if (hasSpoiler && !isSpoilerRevealed) {
onSpoilerPress();
} else {
onPress();
}
};
return (
<TouchableOpacity
style={[styles.compactCard, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}
onPress={handlePress}
activeOpacity={0.7}
>
{/* Header Section - Fixed at top */}
<View style={styles.compactHeader}>
<Text style={[styles.compactUsername, { color: theme.colors.highEmphasis }]}>
{username}
</Text>
{user.vip && (
<View style={styles.miniVipBadge}>
<Text style={styles.miniVipText}>VIP</Text>
</View>
)}
</View>
{/* Rating - Show stars */}
{comment.user_stats?.rating && (
<View style={styles.compactRating}>
{renderCompactStars(comment.user_stats.rating)}
<Text style={[styles.compactRatingText, { color: theme.colors.mediumEmphasis }]}>
{comment.user_stats.rating}/10
</Text>
</View>
)}
{/* Comment Preview - Flexible area that fills space */}
<View style={[styles.commentContainer, shouldBlurContent ? styles.blurredContent : undefined]}>
<Text
style={[styles.compactComment, { color: theme.colors.highEmphasis }]}
numberOfLines={shouldBlurContent ? 3 : undefined}
ellipsizeMode="tail"
>
{shouldBlurContent ? '⚠️ This comment contains spoilers. Tap to reveal.' : truncatedComment}
</Text>
</View>
{/* Meta Info - Fixed at bottom */}
<View style={styles.compactMeta}>
<View style={styles.compactBadges}>
{comment.review && (
<View style={[styles.miniReviewBadgeContainer, { backgroundColor: theme.colors.primary }]}>
<Text style={styles.miniBadgeText}>Review</Text>
</View>
)}
{comment.spoiler && (
<View style={[styles.miniSpoilerBadgeContainer, { backgroundColor: theme.colors.error }]}>
<Text style={styles.miniBadgeText}>Spoiler</Text>
</View>
)}
</View>
<View style={styles.compactStats}>
<Text style={[styles.compactTime, { color: theme.colors.mediumEmphasis }]}>
{formatRelativeTime(comment.created_at)}
</Text>
{comment.likes > 0 && (
<Text style={[styles.compactStat, { color: theme.colors.mediumEmphasis }]}>
👍 {comment.likes}
</Text>
)}
{comment.replies > 0 && (
<Text style={[styles.compactStat, { color: theme.colors.mediumEmphasis }]}>
💬 {comment.replies}
</Text>
)}
</View>
</View>
</TouchableOpacity>
);
};
// 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(
<MaterialIcons key={`full-${i}`} name="star" size={16} color="#FFD700" />
);
}
if (hasHalfStar) {
stars.push(
<MaterialIcons key="half" name="star-half" size={16} color="#FFD700" />
);
}
const emptyStars = 5 - Math.ceil(rating / 2);
for (let i = 0; i < emptyStars; i++) {
stars.push(
<MaterialIcons key={`empty-${i}`} name="star-border" size={16} color="#FFD700" />
);
}
return stars;
};
return (
<Modal
visible={visible}
transparent={true}
animationType="fade"
onRequestClose={onClose}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={onClose}
>
<TouchableOpacity
style={[styles.modalContent, {
backgroundColor: theme.colors.darkGray || '#0A0C0C',
borderColor: theme.colors.border || '#CCCCCC',
borderWidth: 1
}]}
activeOpacity={1}
onPress={() => {}} // Prevent closing when clicking on modal content
>
{/* Close Button */}
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<MaterialIcons name="close" size={24} color={theme.colors.highEmphasis} />
</TouchableOpacity>
{/* User Info */}
<View style={styles.modalHeader}>
<View style={styles.userInfo}>
<Text style={[styles.modalUsername, { color: theme.colors.highEmphasis }]}>
{username}
</Text>
{user.vip && (
<View style={styles.vipBadge}>
<Text style={styles.vipText}>VIP</Text>
</View>
)}
</View>
<Text style={[styles.modalDate, { color: theme.colors.mediumEmphasis }]}>
{formatDate(comment.created_at)}
</Text>
</View>
{/* Rating */}
{comment.user_stats?.rating && (
<View style={styles.modalRating}>
{renderStars(comment.user_stats.rating)}
<Text style={[styles.modalRatingText, { color: theme.colors.mediumEmphasis }]}>
{comment.user_stats.rating}/10
</Text>
</View>
)}
{/* Full Comment */}
{shouldBlurModalContent ? (
<View style={styles.spoilerContainer}>
<Text style={[styles.spoilerWarning, { color: theme.colors.error }]}>
This comment contains spoilers
</Text>
<TouchableOpacity
style={[styles.revealButton, { backgroundColor: theme.colors.primary }]}
onPress={onSpoilerPress}
>
<Text style={[styles.revealButtonText, { color: theme.colors.white }]}>
Reveal Spoilers
</Text>
</TouchableOpacity>
</View>
) : (
<Text style={[styles.modalComment, { color: theme.colors.highEmphasis }]}>
{comment.comment}
</Text>
)}
{/* Comment Meta */}
<View style={styles.modalMeta}>
{comment.review && (
<Text style={[styles.reviewBadge, { color: theme.colors.primary }]}>Review</Text>
)}
{comment.spoiler && (
<Text style={[styles.spoilerBadge, { color: theme.colors.error }]}>Spoiler</Text>
)}
<View style={styles.modalStats}>
{comment.likes > 0 && (
<View style={styles.likesContainer}>
<MaterialIcons name="thumb-up" size={16} color={theme.colors.mediumEmphasis} />
<Text style={[styles.likesText, { color: theme.colors.mediumEmphasis }]}>
{comment.likes}
</Text>
</View>
)}
{comment.replies > 0 && (
<View style={styles.repliesContainer}>
<MaterialIcons name="chat-bubble-outline" size={16} color={theme.colors.mediumEmphasis} />
<Text style={[styles.repliesText, { color: theme.colors.mediumEmphasis }]}>
{comment.replies}
</Text>
</View>
)}
</View>
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
);
};
export const CommentsSection: React.FC<CommentsSectionProps> = ({
imdbId,
type,
season,
episode,
}) => {
const { currentTheme } = useTheme();
const [selectedComment, setSelectedComment] = useState<TraktContentComment | null>(null);
const [modalVisible, setModalVisible] = useState(false);
const [revealedSpoilers, setRevealedSpoilers] = useState<Set<string>>(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 }) => (
<CompactCommentCard
comment={item}
theme={currentTheme}
onPress={() => handleCommentPress(item)}
isSpoilerRevealed={revealedSpoilers.has(item.id.toString())}
onSpoilerPress={() => handleSpoilerPress(item)}
/>
), [currentTheme, handleCommentPress, revealedSpoilers, handleSpoilerPress]);
const renderEmpty = useCallback(() => {
if (loading) return null;
return (
<View style={styles.emptyContainer}>
<MaterialIcons name="chat-bubble-outline" size={48} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}>
{error ? 'Comments unavailable' : 'No comments yet'}
</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.disabled }]}>
{error
? 'This content may not be in Trakt\'s database yet'
: 'Be the first to comment on Trakt.tv'
}
</Text>
</View>
);
}, [loading, error, currentTheme]);
// Don't show section if not authenticated
if (!isAuthenticated) {
return null;
}
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>
Trakt Comments
</Text>
</View>
{error && (
<View style={[styles.errorContainer, { backgroundColor: currentTheme.colors.card }]}>
<MaterialIcons name="error-outline" size={20} color={currentTheme.colors.error} />
<Text style={[styles.errorText, { color: currentTheme.colors.error }]}>
{error}
</Text>
<TouchableOpacity
style={[styles.retryButton, { borderColor: currentTheme.colors.error }]}
onPress={refresh}
>
<Text style={[styles.retryButtonText, { color: currentTheme.colors.error }]}>
Retry
</Text>
</TouchableOpacity>
</View>
)}
{loading && comments.length === 0 && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.loadingText, { color: currentTheme.colors.mediumEmphasis }]}>
Loading comments...
</Text>
</View>
)}
{comments.length === 0 ? (
renderEmpty()
) : (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={comments}
keyExtractor={(item) => item?.id?.toString() || Math.random().toString()}
renderItem={renderComment}
contentContainerStyle={styles.horizontalList}
onEndReached={() => {
if (hasMore && !loading) {
loadMore();
}
}}
onEndReachedThreshold={0.5}
ListFooterComponent={
hasMore ? (
<View style={styles.loadMoreContainer}>
<TouchableOpacity
style={[styles.loadMoreButton, { backgroundColor: currentTheme.colors.card }]}
onPress={loadMore}
disabled={loading}
>
{loading ? (
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
) : (
<>
<Text style={[styles.loadMoreText, { color: currentTheme.colors.primary }]}>
Load More
</Text>
<MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.primary} />
</>
)}
</TouchableOpacity>
</View>
) : null
}
/>
)}
{/* Expanded Comment Modal */}
<ExpandedCommentModal
comment={selectedComment}
visible={modalVisible}
onClose={handleModalClose}
theme={currentTheme}
isSpoilerRevealed={selectedComment ? revealedSpoilers.has(selectedComment.id.toString()) : false}
onSpoilerPress={() => selectedComment && handleSpoilerPress(selectedComment)}
/>
</View>
);
};
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,
},
});

View file

@ -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<TraktContentComment[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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
};
};

View file

@ -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 && (
<MemoizedCommentsSection
imdbId={imdbId}
tmdbId={tmdbId || undefined}
type={Object.keys(groupedEpisodes).length > 0 ? 'show' : 'movie'}
/>
)}
{/* Recommendations Section with skeleton when loading - Lazy loaded */}
{type === 'movie' && shouldLoadSecondaryData && (
<MemoizedMoreLikeThisSection
<MemoizedMoreLikeThisSection
recommendations={recommendations}
loadingRecommendations={loadingRecommendations}
/>

View file

@ -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<number | null> {
public async getTraktIdFromImdbId(imdbId: string, type: 'movie' | 'show'): Promise<number | null> {
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<boolean> {
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<boolean> {
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<number | null> {
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<TraktContentComment[]> {
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<TraktContentComment[]>(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<TraktContentComment[]> {
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<TraktContentComment[]>(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<TraktContentComment[]> {
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<TraktContentComment[]>(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<TraktContentComment[]> {
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<TraktContentComment[]>(endpoint, 'GET');
} catch (error) {
logger.error('[TraktService] Failed to get episode comments:', error);
return [];
}
}
/**
* Handle app state changes to reduce memory pressure
*/