trailer section init

This commit is contained in:
tapframe 2025-10-17 20:18:34 +05:30
parent e435a68aea
commit e9d54bf0d6
5 changed files with 908 additions and 0 deletions

1
TrailerService Submodule

@ -0,0 +1 @@
Subproject commit 2cb2c6d1a3ca60416160bdb28be800fe249207fc

View file

@ -0,0 +1,352 @@
import React, { useState, useEffect, useCallback, memo } from 'react';
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
ActivityIndicator,
Dimensions,
Platform,
Alert,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../../contexts/ThemeContext';
import { logger } from '../../utils/logger';
import TrailerService from '../../services/trailerService';
import TrailerPlayer from '../video/TrailerPlayer';
const { width, height } = Dimensions.get('window');
const isTablet = width >= 768;
interface TrailerVideo {
id: string;
key: string;
name: string;
site: string;
size: number;
type: string;
official: boolean;
published_at: string;
}
interface TrailerModalProps {
visible: boolean;
onClose: () => void;
trailer: TrailerVideo | null;
contentTitle: string;
}
const TrailerModal: React.FC<TrailerModalProps> = memo(({
visible,
onClose,
trailer,
contentTitle
}) => {
const { currentTheme } = useTheme();
const [trailerUrl, setTrailerUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
// Load trailer when modal opens or trailer changes
useEffect(() => {
if (visible && trailer) {
loadTrailer();
} else {
// Reset state when modal closes
setTrailerUrl(null);
setLoading(false);
setError(null);
setIsPlaying(false);
}
}, [visible, trailer]);
const loadTrailer = useCallback(async () => {
if (!trailer) return;
setLoading(true);
setError(null);
setTrailerUrl(null);
try {
const youtubeUrl = `https://www.youtube.com/watch?v=${trailer.key}`;
logger.info('TrailerModal', `Loading trailer: ${trailer.name} (${youtubeUrl})`);
// Use the direct YouTube URL method - much more efficient!
const directUrl = await TrailerService.getTrailerFromYouTubeUrl(
youtubeUrl,
`${contentTitle} - ${trailer.name}`,
new Date(trailer.published_at).getFullYear().toString()
);
if (directUrl) {
setTrailerUrl(directUrl);
setIsPlaying(true);
logger.info('TrailerModal', `Successfully loaded direct trailer URL for: ${trailer.name}`);
} else {
throw new Error('No streaming URL available');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load trailer';
setError(errorMessage);
logger.error('TrailerModal', 'Error loading trailer:', err);
Alert.alert(
'Trailer Unavailable',
'This trailer could not be loaded at this time. Please try again later.',
[{ text: 'OK', style: 'default' }]
);
} finally {
setLoading(false);
}
}, [trailer, contentTitle]);
const handleClose = useCallback(() => {
setIsPlaying(false);
onClose();
}, [onClose]);
const handleTrailerError = useCallback(() => {
setError('Failed to play trailer');
setIsPlaying(false);
}, []);
const handleTrailerEnd = useCallback(() => {
setIsPlaying(false);
}, []);
if (!visible || !trailer) return null;
const modalHeight = isTablet ? height * 0.8 : height * 0.7;
const modalWidth = isTablet ? width * 0.8 : width * 0.95;
return (
<Modal
visible={visible}
transparent={true}
animationType="fade"
onRequestClose={handleClose}
supportedOrientations={['portrait', 'landscape']}
>
<View style={styles.overlay}>
<View style={[styles.modal, {
width: modalWidth,
maxHeight: modalHeight,
backgroundColor: currentTheme.colors.background
}]}>
{/* Header */}
<View style={styles.header}>
<View style={styles.titleContainer}>
<MaterialIcons
name="movie"
size={20}
color={currentTheme.colors.primary}
/>
<Text
style={[styles.title, { color: currentTheme.colors.highEmphasis }]}
numberOfLines={1}
>
{trailer.name}
</Text>
</View>
<TouchableOpacity
onPress={handleClose}
style={styles.closeButton}
hitSlop={{ top: 10, left: 10, right: 10, bottom: 10 }}
>
<MaterialIcons
name="close"
size={24}
color={currentTheme.colors.highEmphasis}
/>
</TouchableOpacity>
</View>
{/* Trailer Info */}
<View style={styles.infoContainer}>
<Text style={[styles.meta, { color: currentTheme.colors.textMuted }]}>
{trailer.type} {new Date(trailer.published_at).getFullYear()}
{trailer.official && ' • Official'}
</Text>
</View>
{/* Player Container */}
<View style={styles.playerContainer}>
{loading && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.loadingText, { color: currentTheme.colors.textMuted }]}>
Loading trailer...
</Text>
</View>
)}
{error && !loading && (
<View style={styles.errorContainer}>
<MaterialIcons
name="error-outline"
size={48}
color={currentTheme.colors.error || '#FF6B6B'}
/>
<Text style={[styles.errorText, { color: currentTheme.colors.textMuted }]}>
{error}
</Text>
<TouchableOpacity
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={loadTrailer}
>
<Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity>
</View>
)}
{trailerUrl && !loading && !error && (
<View style={styles.playerWrapper}>
<TrailerPlayer
trailerUrl={trailerUrl}
autoPlay={isPlaying}
muted={false} // Allow sound in modal
style={styles.player}
hideLoadingSpinner={true}
onLoad={() => logger.info('TrailerModal', 'Trailer loaded successfully')}
onError={handleTrailerError}
onEnd={handleTrailerEnd}
onPlaybackStatusUpdate={(status) => {
if (status.isLoaded && !isPlaying) {
setIsPlaying(true);
}
}}
/>
</View>
)}
</View>
{/* Footer */}
<View style={styles.footer}>
<Text style={[styles.footerText, { color: currentTheme.colors.textMuted }]}>
{contentTitle}
</Text>
</View>
</View>
</View>
</Modal>
);
});
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.9)',
justifyContent: 'center',
alignItems: 'center',
},
modal: {
borderRadius: 16,
overflow: 'hidden',
elevation: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.1)',
},
titleContainer: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
gap: 8,
},
title: {
fontSize: 16,
fontWeight: '600',
flex: 1,
},
closeButton: {
padding: 4,
marginLeft: 8,
},
infoContainer: {
paddingHorizontal: 20,
paddingVertical: 8,
},
meta: {
fontSize: 12,
opacity: 0.8,
},
playerContainer: {
aspectRatio: 16 / 9,
backgroundColor: '#000',
position: 'relative',
},
loadingContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#000',
gap: 16,
},
loadingText: {
fontSize: 14,
opacity: 0.8,
},
errorContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#000',
padding: 20,
gap: 16,
},
errorText: {
fontSize: 14,
textAlign: 'center',
opacity: 0.8,
},
retryButton: {
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 20,
},
retryButtonText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
playerWrapper: {
flex: 1,
},
player: {
flex: 1,
},
footer: {
paddingHorizontal: 20,
paddingVertical: 12,
borderTopWidth: 1,
borderTopColor: 'rgba(255,255,255,0.1)',
},
footerText: {
fontSize: 12,
opacity: 0.6,
textAlign: 'center',
},
});
export default TrailerModal;

View file

@ -0,0 +1,485 @@
import React, { useState, useEffect, memo } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
Dimensions,
Alert,
Platform,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import FastImage from '@d11/react-native-fast-image';
import { useTheme } from '../../contexts/ThemeContext';
import { logger } from '../../utils/logger';
import TrailerService from '../../services/trailerService';
import TrailerModal from './TrailerModal';
const { width } = Dimensions.get('window');
const isTablet = width >= 768;
interface TrailerVideo {
id: string;
key: string;
name: string;
site: string;
size: number;
type: string;
official: boolean;
published_at: string;
}
interface TrailersSectionProps {
tmdbId: number | null;
type: 'movie' | 'tv';
contentId: string;
contentTitle: string;
}
interface CategorizedTrailers {
[key: string]: TrailerVideo[];
}
const TrailersSection: React.FC<TrailersSectionProps> = memo(({
tmdbId,
type,
contentId,
contentTitle
}) => {
const { currentTheme } = useTheme();
const [trailers, setTrailers] = useState<CategorizedTrailers>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTrailer, setSelectedTrailer] = useState<TrailerVideo | null>(null);
const [modalVisible, setModalVisible] = useState(false);
// Fetch trailers from TMDB
useEffect(() => {
if (!tmdbId) return;
const fetchTrailers = async () => {
setLoading(true);
setError(null);
try {
logger.info('TrailersSection', `Fetching trailers for TMDB ID: ${tmdbId}, type: ${type}`);
// First check if the movie/TV show exists
const basicEndpoint = type === 'movie'
? `https://api.themoviedb.org/3/movie/${tmdbId}?api_key=d131017ccc6e5462a81c9304d21476de`
: `https://api.themoviedb.org/3/tv/${tmdbId}?api_key=d131017ccc6e5462a81c9304d21476de`;
const basicResponse = await fetch(basicEndpoint);
if (!basicResponse.ok) {
logger.error('TrailersSection', `TMDB ID ${tmdbId} not found: ${basicResponse.status}`);
setError(`Content not found (TMDB ID: ${tmdbId})`);
return;
}
const videosEndpoint = type === 'movie'
? `https://api.themoviedb.org/3/movie/${tmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`
: `https://api.themoviedb.org/3/tv/${tmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`;
logger.info('TrailersSection', `Fetching videos from: ${videosEndpoint}`);
const response = await fetch(videosEndpoint);
if (!response.ok) {
// 404 is normal - means no videos exist for this content
if (response.status === 404) {
logger.info('TrailersSection', `No videos found for TMDB ID ${tmdbId} (404 response)`);
setTrailers({}); // Empty trailers - section won't render
return;
}
logger.error('TrailersSection', `Videos endpoint failed: ${response.status} ${response.statusText}`);
throw new Error(`Failed to fetch trailers: ${response.status}`);
}
const data = await response.json();
logger.info('TrailersSection', `Received ${data.results?.length || 0} videos for TMDB ID ${tmdbId}`);
const categorized = categorizeTrailers(data.results || []);
const totalVideos = Object.values(categorized).reduce((sum, videos) => sum + videos.length, 0);
if (totalVideos === 0) {
logger.info('TrailersSection', `No videos found for TMDB ID ${tmdbId} - this is normal`);
setTrailers({}); // No trailers available
} else {
logger.info('TrailersSection', `Categorized ${totalVideos} videos into ${Object.keys(categorized).length} categories`);
setTrailers(categorized);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load trailers';
setError(errorMessage);
logger.error('TrailersSection', 'Error fetching trailers:', err);
} finally {
setLoading(false);
}
};
fetchTrailers();
}, [tmdbId, type]);
// Categorize trailers by type
const categorizeTrailers = (videos: any[]): CategorizedTrailers => {
const categories: CategorizedTrailers = {};
videos.forEach(video => {
if (video.site !== 'YouTube') return; // Only YouTube videos
const category = video.type;
if (!categories[category]) {
categories[category] = [];
}
categories[category].push(video);
});
// Sort within each category by published date (newest first)
Object.keys(categories).forEach(category => {
categories[category].sort((a, b) =>
new Date(b.published_at).getTime() - new Date(a.published_at).getTime()
);
});
return categories;
};
// Handle trailer selection
const handleTrailerPress = (trailer: TrailerVideo) => {
setSelectedTrailer(trailer);
setModalVisible(true);
};
// Handle modal close
const handleModalClose = () => {
setModalVisible(false);
setSelectedTrailer(null);
};
// Get thumbnail URL for YouTube video
const getYouTubeThumbnail = (videoId: string, quality: 'default' | 'hq' | 'maxres' = 'hq') => {
const qualities = {
default: `https://img.youtube.com/vi/${videoId}/default.jpg`,
hq: `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`,
maxres: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`
};
return qualities[quality];
};
// Format trailer type for display
const formatTrailerType = (type: string): string => {
switch (type) {
case 'Trailer':
return 'Official Trailers';
case 'Teaser':
return 'Teasers';
case 'Clip':
return 'Clips & Scenes';
case 'Featurette':
return 'Featurettes';
case 'Behind the Scenes':
return 'Behind the Scenes';
default:
return type;
}
};
// Get icon for trailer type
const getTrailerTypeIcon = (type: string): string => {
switch (type) {
case 'Trailer':
return 'movie';
case 'Teaser':
return 'videocam';
case 'Clip':
return 'content-cut';
case 'Featurette':
return 'featured-video';
case 'Behind the Scenes':
return 'camera';
default:
return 'play-circle-outline';
}
};
if (!tmdbId) {
return null; // Don't show if no TMDB ID
}
if (loading) {
return (
<View style={styles.container}>
<View style={styles.header}>
<MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
Trailers
</Text>
</View>
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
<Text style={[styles.loadingText, { color: currentTheme.colors.textMuted }]}>
Loading trailers...
</Text>
</View>
</View>
);
}
if (error) {
return (
<View style={styles.container}>
<View style={styles.header}>
<MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
Trailers
</Text>
</View>
<View style={styles.errorContainer}>
<MaterialIcons name="error-outline" size={24} color={currentTheme.colors.error || '#FF6B6B'} />
<Text style={[styles.errorText, { color: currentTheme.colors.textMuted }]}>
{error}
</Text>
</View>
</View>
);
}
const trailerCategories = Object.keys(trailers);
const totalVideos = Object.values(trailers).reduce((sum, videos) => sum + videos.length, 0);
// Don't show section if no trailers (this is normal for many movies/TV shows)
if (trailerCategories.length === 0 || totalVideos === 0) {
// In development, show a subtle indicator that the section checked but found no trailers
if (__DEV__) {
return (
<View style={styles.container}>
<View style={styles.header}>
<MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
Trailers
</Text>
</View>
<View style={styles.noTrailersContainer}>
<Text style={[styles.noTrailersText, { color: currentTheme.colors.textMuted }]}>
No trailers available
</Text>
</View>
</View>
);
}
return null;
}
return (
<View style={styles.container}>
<View style={styles.header}>
<MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
Trailers
</Text>
</View>
{trailerCategories.map(category => (
<View key={category} style={styles.categoryContainer}>
<View style={styles.categoryHeader}>
<MaterialIcons
name={getTrailerTypeIcon(category) as any}
size={16}
color={currentTheme.colors.primary}
/>
<Text style={[styles.categoryTitle, { color: currentTheme.colors.highEmphasis }]}>
{formatTrailerType(category)}
</Text>
<Text style={[styles.categoryCount, { color: currentTheme.colors.textMuted }]}>
{trailers[category].length}
</Text>
</View>
<View style={styles.trailersGrid}>
{trailers[category].map(trailer => {
return (
<TouchableOpacity
key={trailer.id}
style={styles.trailerCard}
onPress={() => handleTrailerPress(trailer)}
activeOpacity={0.8}
>
<View style={styles.thumbnailContainer}>
<FastImage
source={{ uri: getYouTubeThumbnail(trailer.key, 'hq') }}
style={styles.thumbnail}
resizeMode={FastImage.resizeMode.cover}
/>
<View style={styles.playOverlay}>
<MaterialIcons name="play-arrow" size={32} color="#fff" />
</View>
<View style={styles.durationBadge}>
<Text style={styles.durationText}>
{trailer.size}p
</Text>
</View>
</View>
<View style={styles.trailerInfo}>
<Text
style={[styles.trailerTitle, { color: currentTheme.colors.highEmphasis }]}
numberOfLines={2}
>
{trailer.name}
</Text>
<Text style={[styles.trailerMeta, { color: currentTheme.colors.textMuted }]}>
{new Date(trailer.published_at).getFullYear()}
{trailer.official && ' • Official'}
</Text>
</View>
</TouchableOpacity>
);
})}
</View>
</View>
))}
{/* Trailer Modal */}
<TrailerModal
visible={modalVisible}
onClose={handleModalClose}
trailer={selectedTrailer}
contentTitle={contentTitle}
/>
</View>
);
});
const styles = StyleSheet.create({
container: {
paddingHorizontal: 16,
marginTop: 24,
marginBottom: 16,
},
header: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
gap: 8,
},
headerTitle: {
fontSize: 18,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 1,
opacity: 0.9,
},
loadingContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 32,
gap: 12,
},
loadingText: {
fontSize: 14,
opacity: 0.7,
},
errorContainer: {
alignItems: 'center',
paddingVertical: 32,
gap: 8,
},
errorText: {
fontSize: 14,
textAlign: 'center',
opacity: 0.7,
},
categoryContainer: {
marginBottom: 24,
},
categoryHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
gap: 8,
},
categoryTitle: {
fontSize: 16,
fontWeight: '600',
flex: 1,
},
categoryCount: {
fontSize: 12,
opacity: 0.6,
backgroundColor: 'rgba(255,255,255,0.1)',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 10,
},
trailersGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
trailerCard: {
width: isTablet ? (width - 32 - 24) / 3 : (width - 32 - 12) / 2,
backgroundColor: 'rgba(255,255,255,0.05)',
borderRadius: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
overflow: 'hidden',
},
thumbnailContainer: {
position: 'relative',
aspectRatio: 16 / 9,
},
thumbnail: {
width: '100%',
height: '100%',
},
playOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.3)',
justifyContent: 'center',
alignItems: 'center',
},
durationBadge: {
position: 'absolute',
bottom: 8,
right: 8,
backgroundColor: 'rgba(0,0,0,0.8)',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
durationText: {
color: '#fff',
fontSize: 10,
fontWeight: '600',
},
trailerInfo: {
padding: 12,
},
trailerTitle: {
fontSize: 13,
fontWeight: '600',
lineHeight: 16,
marginBottom: 4,
},
trailerMeta: {
fontSize: 11,
opacity: 0.7,
},
noTrailersContainer: {
alignItems: 'center',
paddingVertical: 16,
},
noTrailersText: {
fontSize: 14,
opacity: 0.6,
fontStyle: 'italic',
},
});
export default TrailersSection;

View file

@ -26,6 +26,7 @@ import { MovieContent } from '../components/metadata/MovieContent';
import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
import { RatingsSection } from '../components/metadata/RatingsSection';
import { CommentsSection, CommentBottomSheet } from '../components/metadata/CommentsSection';
import TrailersSection from '../components/metadata/TrailersSection';
import { RouteParams, Episode } from '../types/metadata';
import Animated, {
useAnimatedStyle,
@ -992,6 +993,16 @@ const MetadataScreen: React.FC = () => {
</Animated.View>
)}
{/* Trailers Section - Lazy loaded */}
{shouldLoadSecondaryData && tmdbId && settings.enrichMetadataWithTMDB && (
<TrailersSection
tmdbId={tmdbId}
type={Object.keys(groupedEpisodes).length > 0 ? 'tv' : 'movie'}
contentId={id}
contentTitle={metadata?.name || (metadata as any)?.title || 'Unknown'}
/>
)}
{/* Comments Section - Lazy loaded */}
{shouldLoadSecondaryData && imdbId && (
<MemoizedCommentsSection

View file

@ -317,6 +317,65 @@ export class TrailerService {
};
}
/**
* Fetches trailer directly from a known YouTube URL
* @param youtubeUrl - The YouTube URL to process
* @param title - Optional title for logging/caching
* @param year - Optional year for logging/caching
* @returns Promise<string | null> - The direct streaming URL or null if failed
*/
static async getTrailerFromYouTubeUrl(youtubeUrl: string, title?: string, year?: string): Promise<string | null> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT);
const params = new URLSearchParams();
params.append('youtube_url', youtubeUrl);
if (title) params.append('title', title);
if (year) params.append('year', year.toString());
const url = `${this.ENV_LOCAL_BASE}${this.ENV_LOCAL_TRAILER_PATH}?${params.toString()}`;
logger.info('TrailerService', `Fetching trailer directly from YouTube URL: ${youtubeUrl}`);
logger.info('TrailerService', `Direct trailer request URL: ${url}`);
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': 'Nuvio/1.0',
},
signal: controller.signal,
});
clearTimeout(timeoutId);
logger.info('TrailerService', `Direct trailer response: status=${response.status} ok=${response.ok}`);
if (!response.ok) {
logger.warn('TrailerService', `Direct trailer failed: ${response.status} ${response.statusText}`);
return null;
}
const data = await response.json();
if (!data.url || !this.isValidTrailerUrl(data.url)) {
logger.warn('TrailerService', `Invalid trailer URL from direct fetch: ${data.url}`);
return null;
}
logger.info('TrailerService', `Successfully got direct trailer: ${String(data.url).substring(0, 80)}...`);
return data.url;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
logger.warn('TrailerService', `Direct trailer request timed out after ${this.TIMEOUT}ms`);
} else {
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
logger.error('TrailerService', `Error in direct trailer fetch: ${msg}`);
}
return null;
}
}
/**
* Switch between local server and XPrime API
* @param useLocal - true for local server, false for XPrime