diff --git a/src/components/metadata/CommentsSection.tsx b/src/components/metadata/CommentsSection.tsx
index c390889a..c7dcb47f 100644
--- a/src/components/metadata/CommentsSection.tsx
+++ b/src/components/metadata/CommentsSection.tsx
@@ -10,6 +10,7 @@ import {
Alert,
ScrollView,
Animated,
+ Linking,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import TraktIcon from '../../../assets/rating-icons/trakt.svg';
@@ -35,6 +36,142 @@ interface CommentItemProps {
theme: any;
}
+// Minimal markdown renderer with inline spoiler handling
+const MarkdownText: React.FC<{
+ text: string;
+ theme: any;
+ numberOfLines?: number;
+ revealedInlineSpoilers: boolean;
+ onSpoilerPress?: () => void;
+ textStyle?: any;
+}> = ({ text, theme, numberOfLines, revealedInlineSpoilers, onSpoilerPress, textStyle }) => {
+ // Regexes for simple markdown
+ const linkRegex = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g; // [text](url)
+ const boldRegex = /\*\*([^*]+)\*\*/g; // **bold**
+ const italicRegex = /\*([^*]+)\*/g; // *italic*
+ const codeRegex = /`([^`]+)`/g; // `code`
+ const spoilerRegex = /\[spoiler\]([\s\S]*?)\[\/spoiler\]/gi;
+
+ // Tokenize spoilers first to keep nesting simple
+ const spoilerTokens: Array<{ type: 'spoiler' | 'text'; content: string }> = [];
+ let lastIndex = 0;
+ let match: RegExpExecArray | null;
+ while ((match = spoilerRegex.exec(text)) !== null) {
+ if (match.index > lastIndex) {
+ spoilerTokens.push({ type: 'text', content: text.slice(lastIndex, match.index) });
+ }
+ spoilerTokens.push({ type: 'spoiler', content: match[1] });
+ lastIndex = spoilerRegex.lastIndex;
+ }
+ if (lastIndex < text.length) {
+ spoilerTokens.push({ type: 'text', content: text.slice(lastIndex) });
+ }
+
+ const renderInline = (segment: string, keyPrefix: string) => {
+ // Process code
+ const codeSplit = segment.split(codeRegex);
+ const codeNodes: React.ReactNode[] = [];
+ for (let i = 0; i < codeSplit.length; i++) {
+ if (i % 2 === 1) {
+ codeNodes.push(
+
+ {codeSplit[i]}
+
+ );
+ } else {
+ // process bold and italic and links inside normal text
+ let chunk = codeSplit[i] ?? '';
+ const parts: React.ReactNode[] = [];
+
+ // Links
+ let cursor = 0;
+ let linkMatch: RegExpExecArray | null;
+ while ((linkMatch = linkRegex.exec(chunk)) !== null) {
+ const before = chunk.slice(cursor, linkMatch.index);
+ if (before) parts.push({before});
+ const label = linkMatch[1];
+ const url = linkMatch[2];
+ parts.push(
+ Linking.openURL(url)}
+ suppressHighlighting
+ >
+ {label}
+
+ );
+ cursor = linkMatch.index + linkMatch[0].length;
+ }
+ if (cursor < chunk.length) {
+ parts.push({chunk.slice(cursor)});
+ }
+
+ // Wrap bold & italic via nested Text by replacing markers
+ const applyFormat = (nodes: React.ReactNode[]): React.ReactNode[] => {
+ return nodes.flatMap((node, idx) => {
+ if (typeof node !== 'string' && !(node as any).props?.children) return node;
+ const str = typeof node === 'string' ? node : (node as any).props.children as string;
+ if (typeof str !== 'string') return node;
+
+ // bold
+ const boldSplit = str.split(boldRegex);
+ const boldNodes: React.ReactNode[] = [];
+ for (let b = 0; b < boldSplit.length; b++) {
+ if (b % 2 === 1) {
+ boldNodes.push({boldSplit[b]});
+ } else {
+ // italic inside non-bold chunk
+ const italSplit = boldSplit[b].split(italicRegex);
+ for (let it = 0; it < italSplit.length; it++) {
+ if (it % 2 === 1) {
+ boldNodes.push({italSplit[it]});
+ } else {
+ if (italSplit[it]) boldNodes.push({italSplit[it]});
+ }
+ }
+ }
+ }
+ return boldNodes;
+ });
+ };
+
+ codeNodes.push(
+
+ {applyFormat(parts)}
+
+ );
+ }
+ }
+ return codeNodes;
+ };
+
+ return (
+
+ {spoilerTokens.map((tok, idx) => {
+ if (tok.type === 'text') {
+ return {renderInline(tok.content, `seg-${idx}`)};
+ }
+ if (revealedInlineSpoilers) {
+ return {renderInline(tok.content, `spl-${idx}`)};
+ }
+ return (
+
+
+
+ [spoiler]
+
+
+
+ );
+ })}
+
+ );
+};
+
// Compact comment card for horizontal scrolling
const CompactCommentCard: React.FC<{
comment: TraktContentComment;
@@ -67,9 +204,7 @@ const CompactCommentCard: React.FC<{
const hasSpoiler = comment.spoiler;
const shouldBlurContent = hasSpoiler && !isSpoilerRevealed;
- const truncatedComment = comment.comment.length > 100
- ? comment.comment.substring(0, 100) + '...'
- : comment.comment;
+ // We render markdown with inline spoilers; limit lines to keep card compact
// Format relative time
const formatRelativeTime = (dateString: string) => {
@@ -181,13 +316,18 @@ const CompactCommentCard: React.FC<{
{/* Comment Preview - Flexible area that fills space */}
-
- {shouldBlurContent ? '⚠️ This comment contains spoilers. Tap to reveal.' : truncatedComment}
-
+ {shouldBlurContent ? (
+ ⚠️ This comment contains spoilers. Tap to reveal.
+ ) : (
+
+ )}
{/* Meta Info - Fixed at bottom */}
@@ -371,7 +511,7 @@ const ExpandedCommentBottomSheet: React.FC<{
)}
- {/* Full Comment */}
+ {/* Full Comment (Markdown with inline spoilers) */}
{shouldBlurModalContent ? (
@@ -388,9 +528,14 @@ const ExpandedCommentBottomSheet: React.FC<{
) : (
-
- {comment.comment}
-
+
+
+
)}
{/* Comment Meta */}
@@ -788,7 +933,7 @@ export const CommentBottomSheet: React.FC<{
)}
- {/* Full Comment */}
+ {/* Full Comment (Markdown with inline spoilers) */}
{shouldBlurModalContent ? (
@@ -805,9 +950,14 @@ export const CommentBottomSheet: React.FC<{
) : (
-
- {comment.comment}
-
+
+
+
)}
{/* Comment Meta */}
diff --git a/src/components/metadata/MetadataDetails.tsx b/src/components/metadata/MetadataDetails.tsx
index 2532b761..13626f29 100644
--- a/src/components/metadata/MetadataDetails.tsx
+++ b/src/components/metadata/MetadataDetails.tsx
@@ -19,6 +19,7 @@ import Animated, {
} from 'react-native-reanimated';
import { useTheme } from '../../contexts/ThemeContext';
import { isMDBListEnabled } from '../../screens/MDBListSettingsScreen';
+import { getAgeRatingColor } from '../../utils/ageRatingColors';
// MetadataSourceSelector removed
interface MetadataDetailsProps {
@@ -153,7 +154,11 @@ function formatRuntime(runtime: string): string {
)}
{metadata.certification && (
- {metadata.certification}
+ {metadata.certification}
)}
{metadata.imdbRating && !isMDBEnabled && (
@@ -273,6 +278,15 @@ const styles = StyleSheet.create({
textTransform: 'uppercase',
opacity: 0.9,
},
+ premiumOutlinedText: {
+ // Subtle premium outline effect for letters
+ textShadowColor: 'rgba(0, 0, 0, 0.3)',
+ textShadowOffset: { width: 0, height: 1 },
+ textShadowRadius: 2,
+ // Enhanced letter definition
+ fontWeight: '800',
+ letterSpacing: 0.5,
+ },
ratingContainer: {
flexDirection: 'row',
alignItems: 'center',
diff --git a/src/utils/ageRatingColors.ts b/src/utils/ageRatingColors.ts
new file mode 100644
index 00000000..d0e2500f
--- /dev/null
+++ b/src/utils/ageRatingColors.ts
@@ -0,0 +1,216 @@
+/**
+ * Premium color mapping for age ratings
+ * Provides consistent, visually appealing colors for different age rating systems
+ */
+
+// Movie Ratings (MPA) - Premium Colors
+export const MOVIE_RATING_COLORS = {
+ 'G': '#00C851', // Vibrant Green - General Audiences
+ 'PG': '#FFBB33', // Golden Yellow - Parental Guidance Suggested
+ 'PG-13': '#FF8800', // Premium Orange - Parents Strongly Cautioned
+ 'R': '#FF4444', // Premium Red - Restricted
+ 'NC-17': '#CC0000', // Deep Crimson - No One 17 and Under Admitted
+ 'UNRATED': '#666666', // Neutral Gray - Unrated content
+ 'NOT RATED': '#666666', // Neutral Gray - Not Rated content
+} as const;
+
+// TV Ratings (TV Parental Guidelines) - Premium Colors
+export const TV_RATING_COLORS = {
+ 'TV-Y': '#00C851', // Vibrant Green - All Children
+ 'TV-Y7': '#66BB6A', // Light Green - Directed to Older Children
+ 'TV-G': '#00C851', // Vibrant Green - General Audience
+ 'TV-PG': '#FFBB33', // Golden Yellow - Parental Guidance Suggested
+ 'TV-14': '#FF8800', // Premium Orange - Parents Strongly Cautioned
+ 'TV-MA': '#FF4444', // Premium Red - Mature Audience Only
+ 'NR': '#666666', // Neutral Gray - Not Rated
+ 'UNRATED': '#666666', // Neutral Gray - Unrated content
+} as const;
+
+// Common/Generic age rating patterns that might appear
+export const COMMON_RATING_PATTERNS = {
+ // Movie patterns
+ 'G': '#00C851',
+ 'PG': '#FFBB33',
+ 'PG-13': '#FF8800',
+ 'R': '#FF4444',
+ 'NC-17': '#CC0000',
+ 'UNRATED': '#666666',
+ 'NOT RATED': '#666666',
+
+ // TV patterns
+ 'TV-Y': '#00C851',
+ 'TV-Y7': '#66BB6A',
+ 'TV-G': '#00C851',
+ 'TV-PG': '#FFBB33',
+ 'TV-14': '#FF8800',
+ 'TV-MA': '#FF4444',
+ 'NR': '#666666',
+
+ // International/common patterns
+ 'U': '#00C851', // Universal (UK) - Green
+ 'U/A': '#00C851', // Universal/Adult (India) - Green
+ 'A': '#FF8800', // Adult (India) - Orange
+ 'S': '#FF4444', // Restricted (India) - Red
+ 'UA': '#FFBB33', // Parental Guidance (India) - Yellow
+ '12': '#FF8800', // 12+ (Various countries) - Orange
+ '12A': '#FFBB33', // 12A (UK) - Yellow
+ '15': '#FF4444', // 15+ (Various countries) - Red
+ '18': '#CC0000', // 18+ (Various countries) - Dark Red
+ '18+': '#CC0000', // 18+ - Dark Red
+ 'R18': '#CC0000', // R18 (Australia) - Dark Red
+ 'X': '#CC0000', // X (Adult) - Dark Red
+} as const;
+
+/**
+ * Get the appropriate color for a movie rating
+ * @param rating - The movie rating (e.g., 'PG-13', 'R', 'G')
+ * @returns Hex color code for the rating
+ */
+export function getMovieRatingColor(rating: string | null | undefined): string {
+ if (!rating) return '#666666'; // Default gray for no rating
+
+ const normalizedRating = rating.toUpperCase().trim();
+
+ // Direct lookup in movie ratings
+ if (normalizedRating in MOVIE_RATING_COLORS) {
+ return MOVIE_RATING_COLORS[normalizedRating as keyof typeof MOVIE_RATING_COLORS];
+ }
+
+ // Check common patterns
+ if (normalizedRating in COMMON_RATING_PATTERNS) {
+ return COMMON_RATING_PATTERNS[normalizedRating as keyof typeof COMMON_RATING_PATTERNS];
+ }
+
+ // Special handling for some common variations
+ if (normalizedRating.includes('PG') && normalizedRating.includes('13')) {
+ return '#FF8800'; // PG-13 variations
+ }
+
+ if (normalizedRating.includes('TV') && normalizedRating.includes('MA')) {
+ return '#FF4444'; // TV-MA variations
+ }
+
+ // Default fallback
+ return '#666666';
+}
+
+/**
+ * Get the appropriate color for a TV rating
+ * @param rating - The TV rating (e.g., 'TV-14', 'TV-MA', 'TV-Y')
+ * @returns Hex color code for the rating
+ */
+export function getTVRatingColor(rating: string | null | undefined): string {
+ if (!rating) return '#666666'; // Default gray for no rating
+
+ const normalizedRating = rating.toUpperCase().trim();
+
+ // Direct lookup in TV ratings
+ if (normalizedRating in TV_RATING_COLORS) {
+ return TV_RATING_COLORS[normalizedRating as keyof typeof TV_RATING_COLORS];
+ }
+
+ // Check common patterns
+ if (normalizedRating in COMMON_RATING_PATTERNS) {
+ return COMMON_RATING_PATTERNS[normalizedRating as keyof typeof COMMON_RATING_PATTERNS];
+ }
+
+ // Special handling for TV rating variations
+ if (normalizedRating.startsWith('TV-')) {
+ const tvRating = normalizedRating as keyof typeof TV_RATING_COLORS;
+ if (tvRating in TV_RATING_COLORS) {
+ return TV_RATING_COLORS[tvRating];
+ }
+ }
+
+ // Default fallback
+ return '#666666';
+}
+
+/**
+ * Get the appropriate color for any content rating based on content type
+ * @param rating - The rating string
+ * @param contentType - 'movie' or 'series' to determine which rating system to use
+ * @returns Hex color code for the rating
+ */
+export function getAgeRatingColor(rating: string | null | undefined, contentType: 'movie' | 'series' = 'movie'): string {
+ if (!rating) return '#666666';
+
+ // For movies, prioritize movie rating system
+ if (contentType === 'movie') {
+ return getMovieRatingColor(rating);
+ }
+
+ // For series/TV shows, check TV ratings first, then fall back to movie ratings
+ const tvColor = getTVRatingColor(rating);
+ if (tvColor !== '#666666') {
+ return tvColor;
+ }
+
+ // Fallback to movie rating system for series
+ return getMovieRatingColor(rating);
+}
+
+/**
+ * Get a human-readable description for an age rating
+ * @param rating - The rating string
+ * @param contentType - Content type for context
+ * @returns Description of what the rating means
+ */
+export function getAgeRatingDescription(rating: string | null | undefined, contentType: 'movie' | 'series' = 'movie'): string {
+ if (!rating) return 'Not Rated';
+
+ const normalizedRating = rating.toUpperCase().trim();
+
+ // Movie rating descriptions
+ const movieDescriptions: Record = {
+ 'G': 'General Audiences - All ages admitted',
+ 'PG': 'Parental Guidance Suggested - Some material may not be suitable for children',
+ 'PG-13': 'Parents Strongly Cautioned - Some material may be inappropriate for children under 13',
+ 'R': 'Restricted - Under 17 requires accompanying parent or adult guardian',
+ 'NC-17': 'No One 17 and Under Admitted - Clearly adult content',
+ 'UNRATED': 'Unrated - Content rating not assigned',
+ 'NOT RATED': 'Not Rated - Content rating not assigned',
+ };
+
+ // TV rating descriptions
+ const tvDescriptions: Record = {
+ 'TV-Y': 'All Children - Designed to be appropriate for all children',
+ 'TV-Y7': 'Directed to Older Children - Designed for children age 7 and above',
+ 'TV-G': 'General Audience - Most parents would find suitable for all ages',
+ 'TV-PG': 'Parental Guidance Suggested - May contain material unsuitable for younger children',
+ 'TV-14': 'Parents Strongly Cautioned - May contain material unsuitable for children under 14',
+ 'TV-MA': 'Mature Audience Only - Specifically designed for adults',
+ 'NR': 'Not Rated - Content rating not assigned',
+ 'UNRATED': 'Unrated - Content rating not assigned',
+ };
+
+ if (contentType === 'movie' && normalizedRating in movieDescriptions) {
+ return movieDescriptions[normalizedRating];
+ }
+
+ if (contentType === 'series' && normalizedRating in tvDescriptions) {
+ return tvDescriptions[normalizedRating];
+ }
+
+ // Fallback descriptions for common international ratings
+ const commonDescriptions: Record = {
+ 'U': 'Universal - Suitable for all ages',
+ 'U/A': 'Universal with Adult guidance - Parental discretion advised',
+ 'A': 'Adults only - Not suitable for children',
+ 'S': 'Restricted - Not suitable for children',
+ 'UA': 'Parental Guidance - Parental discretion advised',
+ '12': 'Suitable for ages 12 and above',
+ '12A': 'Suitable for ages 12 and above when accompanied by an adult',
+ '15': 'Suitable for ages 15 and above',
+ '18': 'Suitable for ages 18 and above only',
+ '18+': 'Adult content - 18 and above only',
+ 'R18': 'Restricted 18 - Adult content only',
+ 'X': 'Adult content - Explicit material',
+ };
+
+ if (normalizedRating in commonDescriptions) {
+ return commonDescriptions[normalizedRating];
+ }
+
+ return `${rating} - Rating information not available`;
+}