Merge pull request #270 from AdityasahuX07/patch-7

Enhance AppleTVHero component functionality by Adding Play and Save to library Button.
This commit is contained in:
Nayif 2025-12-11 15:02:49 +05:30 committed by GitHub
commit 52065a1462
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -39,6 +39,12 @@ import { useSettings } from '../../hooks/useSettings';
import { useTrailer } from '../../contexts/TrailerContext';
import TrailerService from '../../services/trailerService';
import TrailerPlayer from '../video/TrailerPlayer';
import { useLibrary } from '../../hooks/useLibrary';
import { useToast } from '../../contexts/ToastContext';
import { useTraktContext } from '../../contexts/TraktContext';
import { BlurView as ExpoBlurView } from 'expo-blur';
import { useWatchProgress } from '../../hooks/useWatchProgress';
import { streamCacheService } from '../../services/streamCacheService';
interface AppleTVHeroProps {
featuredContent: StreamingContent | null;
@ -144,6 +150,16 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
const insets = useSafeAreaInsets();
const { settings, updateSetting } = useSettings();
const { isTrailerPlaying: globalTrailerPlaying, setTrailerPlaying } = useTrailer();
const { toggleLibrary, isInLibrary: checkIsInLibrary } = useLibrary();
const { showSaved, showTraktSaved, showRemoved, showTraktRemoved } = useToast();
const { isAuthenticated: isTraktAuthenticated } = useTraktContext();
// Library and watch state
const [inLibrary, setInLibrary] = useState(false);
const [isInWatchlist, setIsInWatchlist] = useState(false);
const [isWatched, setIsWatched] = useState(false);
const [playButtonText, setPlayButtonText] = useState('Play');
const [type, setType] = useState<'movie' | 'series'>('movie');
// Create internal scrollY if not provided externally
const internalScrollY = useSharedValue(0);
@ -185,6 +201,18 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
const currentItem = items[currentIndex] || null;
// Use watch progress hook
const {
watchProgress,
getPlayButtonText: getProgressPlayButtonText,
loadWatchProgress
} = useWatchProgress(
currentItem?.id || '',
type,
undefined,
[] // Pass episodes if you have them for series
);
// Animation values
const dragProgress = useSharedValue(0);
const dragDirection = useSharedValue(0); // -1 for left, 1 for right
@ -196,6 +224,15 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
const trailerMuted = settings?.trailerMuted ?? true;
const heroOpacity = useSharedValue(0); // Start hidden for smooth fade-in
// Handler for trailer end
const handleTrailerEnd = useCallback(() => {
logger.info('[AppleTVHero] Trailer ended');
setTrailerPlaying(false);
// Fade back to thumbnail
trailerOpacity.value = withTiming(0, { duration: 300 });
thumbnailOpacity.value = withTiming(1, { duration: 300 });
}, [setTrailerPlaying, trailerOpacity, thumbnailOpacity]);
// Animated style for trailer container - 60% height with zoom
const trailerContainerStyle = useAnimatedStyle(() => {
// Faster fade out during drag - complete fade by 0.3 progress instead of 1.0
@ -480,19 +517,196 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
logger.error('[AppleTVHero] Trailer playback error');
}, [trailerOpacity, thumbnailOpacity, setTrailerPlaying]);
// Handle trailer end
const handleTrailerEnd = useCallback(() => {
logger.info('[AppleTVHero] Trailer ended');
setTrailerPlaying(false);
// Update state when current item changes and load watch progress
useEffect(() => {
if (currentItem) {
setType(currentItem.type as 'movie' | 'series');
checkItemStatus(currentItem.id);
loadWatchProgress();
}
}, [currentItem, loadWatchProgress]);
// Reset trailer state
setTrailerReady(false);
setTrailerPreloaded(false);
// Update play button text and watched state when watch progress changes
useEffect(() => {
if (currentItem) {
const buttonText = getProgressPlayButtonText();
setPlayButtonText(buttonText);
// Smooth fade back to thumbnail
trailerOpacity.value = withTiming(0, { duration: 500 });
thumbnailOpacity.value = withTiming(1, { duration: 500 });
}, [trailerOpacity, thumbnailOpacity, setTrailerPlaying]);
// Update watched state based on progress
if (watchProgress) {
const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
setIsWatched(progressPercent >= 85); // Consider watched if 85% or more completed
} else {
setIsWatched(false);
}
}
}, [watchProgress, getProgressPlayButtonText, currentItem]);
// Function to check item status
const checkItemStatus = useCallback(async (itemId: string) => {
try {
// Check if item is in library
const libraryStatus = checkIsInLibrary(itemId);
setInLibrary(libraryStatus);
// TODO: Check Trakt watchlist status if authenticated
if (isTraktAuthenticated) {
// await traktService.isInWatchlist(itemId);
setIsInWatchlist(Math.random() > 0.5); // Replace with actual Trakt call
}
} catch (error) {
logger.error('[AppleTVHero] Error checking item status:', error);
}
}, [checkIsInLibrary, isTraktAuthenticated]);
// Update the handleSaveAction function:
const handleSaveAction = useCallback(async (e?: any) => {
if (e) {
e.stopPropagation();
e.preventDefault();
}
if (!currentItem) return;
const wasInLibrary = inLibrary;
const wasInWatchlist = isInWatchlist;
// Update local state immediately for responsiveness
setInLibrary(!wasInLibrary);
try {
// Toggle library using the useLibrary hook
const success = await toggleLibrary(currentItem);
if (success) {
logger.info('[AppleTVHero] Successfully toggled library:', currentItem.name);
} else {
logger.warn('[AppleTVHero] Library toggle returned false');
}
// If authenticated with Trakt, also toggle Trakt watchlist
if (isTraktAuthenticated) {
setIsInWatchlist(!wasInWatchlist);
// TODO: Replace with your actual Trakt service call
// await traktService.toggleWatchlist(currentItem.id, !wasInWatchlist);
logger.info('[AppleTVHero] Toggled Trakt watchlist');
}
} catch (error) {
logger.error('[AppleTVHero] Error toggling library:', error);
// Revert state on error
setInLibrary(wasInLibrary);
if (isTraktAuthenticated) {
setIsInWatchlist(wasInWatchlist);
}
}
}, [currentItem, inLibrary, isInWatchlist, isTraktAuthenticated, toggleLibrary, showSaved, showTraktSaved, showRemoved, showTraktRemoved]);
// Play button handler - navigates to Streams screen with progress data if available
const handlePlayAction = useCallback(async () => {
logger.info('[AppleTVHero] Play button pressed for:', currentItem?.name);
if (!currentItem) return;
// Stop any playing trailer
try {
setTrailerPlaying(false);
} catch {}
// Check if we should resume based on watch progress
const shouldResume = watchProgress &&
watchProgress.currentTime > 0 &&
(watchProgress.currentTime / watchProgress.duration) < 0.85;
logger.info('[AppleTVHero] Should resume:', shouldResume, watchProgress);
try {
// Check if we have a cached stream for this content
const episodeId = currentItem.type === 'series' && watchProgress?.episodeId
? watchProgress.episodeId
: undefined;
logger.info('[AppleTVHero] Looking for cached stream with episodeId:', episodeId);
const cachedStream = await streamCacheService.getCachedStream(currentItem.id, currentItem.type, episodeId);
if (cachedStream && cachedStream.stream?.url) {
// We have a valid cached stream, navigate directly to player
logger.info('[AppleTVHero] Using cached stream for:', currentItem.name);
// Determine the player route based on platform
const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid';
// Navigate directly to player with cached stream data AND RESUME DATA
navigation.navigate(playerRoute as any, {
uri: cachedStream.stream.url,
title: cachedStream.metadata?.name || currentItem.name,
episodeTitle: cachedStream.episodeTitle,
season: cachedStream.season,
episode: cachedStream.episode,
quality: (cachedStream.stream.title?.match(/(\d+)p/) || [])[1] || undefined,
year: cachedStream.metadata?.year || currentItem.year,
streamProvider: cachedStream.stream.addonId || cachedStream.stream.addonName || cachedStream.stream.name,
streamName: cachedStream.stream.name || cachedStream.stream.title || 'Unnamed Stream',
headers: cachedStream.stream.headers || undefined,
forceVlc: false,
id: currentItem.id,
type: currentItem.type,
episodeId: episodeId,
imdbId: cachedStream.imdbId || cachedStream.metadata?.imdbId || currentItem.imdb_id,
backdrop: cachedStream.metadata?.backdrop || currentItem.banner,
videoType: undefined, // Let player auto-detect
// ADD RESUME DATA if we should resume
...(shouldResume && watchProgress && {
resumeTime: watchProgress.currentTime,
duration: watchProgress.duration
})
} as any);
return;
}
// No cached stream, navigate to Streams screen with resume data
logger.info('[AppleTVHero] No cached stream, navigating to StreamsScreen for:', currentItem.name);
const navigationParams: any = {
id: currentItem.id,
type: currentItem.type,
title: currentItem.name,
metadata: {
poster: currentItem.poster,
banner: currentItem.banner,
releaseInfo: currentItem.releaseInfo,
genres: currentItem.genres
}
};
// Add resume data if we have progress that's not near completion
if (shouldResume && watchProgress) {
navigationParams.resumeTime = watchProgress.currentTime;
navigationParams.duration = watchProgress.duration;
navigationParams.episodeId = watchProgress.episodeId;
logger.info('[AppleTVHero] Passing resume data to Streams:', watchProgress.currentTime, watchProgress.duration);
}
navigation.navigate('Streams', navigationParams);
} catch (error) {
logger.error('[AppleTVHero] Error handling play action:', error);
// Fallback to StreamsScreen on any error
navigation.navigate('Streams', {
id: currentItem.id,
type: currentItem.type,
title: currentItem.name,
metadata: {
poster: currentItem.poster,
banner: currentItem.banner,
releaseInfo: currentItem.releaseInfo,
genres: currentItem.genres
}
});
}
}, [currentItem, navigation, setTrailerPlaying, watchProgress]);
// Handle fullscreen toggle
const handleFullscreenToggle = useCallback(async () => {
@ -569,33 +783,6 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
);
}, [currentIndex, setTrailerPlaying, trailerOpacity, thumbnailOpacity]);
// Preload next and previous images for instant swiping
useEffect(() => {
if (items.length <= 1) return;
const prevIdx = (currentIndex - 1 + items.length) % items.length;
const nextIdx = (currentIndex + 1) % items.length;
const prevItem = items[prevIdx];
const nextItem = items[nextIdx];
const urlsToPreload: { uri: string }[] = [];
if (prevItem) {
const url = prevItem.banner || prevItem.poster;
if (url) urlsToPreload.push({ uri: url });
}
if (nextItem) {
const url = nextItem.banner || nextItem.poster;
if (url) urlsToPreload.push({ uri: url });
}
if (urlsToPreload.length > 0) {
FastImage.preload(urlsToPreload);
}
}, [currentIndex, items]);
// Callback for updating interaction time
const updateInteractionTime = useCallback(() => {
lastInteractionRef.current = Date.now();
@ -972,37 +1159,61 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
style={logoAnimatedStyle}
>
{currentItem.logo && !logoError[currentIndex] ? (
<View
style={[
styles.logoContainer,
logoHeights[currentIndex] && logoHeights[currentIndex] < 80
? { marginBottom: 4 } // Minimal spacing for small logos
: { marginBottom: 8 } // Small spacing for normal logos
]}
onLayout={(event) => {
const { height } = event.nativeEvent.layout;
setLogoHeights((prev) => ({ ...prev, [currentIndex]: height }));
}}
>
<Image
source={{ uri: currentItem.logo }}
style={styles.logo}
resizeMode="contain"
onLoad={() => setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))}
onError={() => {
setLogoError((prev) => ({ ...prev, [currentIndex]: true }));
logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo);
<TouchableOpacity
activeOpacity={0.7}
onPress={() => {
if (currentItem) {
navigation.navigate('Metadata', {
id: currentItem.id,
type: currentItem.type,
});
}
}}
/>
</View>
) : (
<View style={styles.titleContainer}>
<Text style={styles.title} numberOfLines={2}>
{currentItem.name}
</Text>
</View>
)}
</Animated.View>
>
<View
style={[
styles.logoContainer,
logoHeights[currentIndex] && logoHeights[currentIndex] < 80
? { marginBottom: 4 } // Minimal spacing for small logos
: { marginBottom: 8 } // Small spacing for normal logos
]}
onLayout={(event) => {
const { height } = event.nativeEvent.layout;
setLogoHeights((prev) => ({ ...prev, [currentIndex]: height }));
}}
>
<Image
source={{ uri: currentItem.logo }}
style={styles.logo}
resizeMode="contain"
onLoad={() => setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))}
onError={() => {
setLogoError((prev) => ({ ...prev, [currentIndex]: true }));
logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo);
}}
/>
</View>
</TouchableOpacity>
) : (
<TouchableOpacity
activeOpacity={0.8}
onPress={() => {
if (currentItem) {
navigation.navigate('Metadata', {
id: currentItem.id,
type: currentItem.type,
});
}
}}
>
<View style={styles.titleContainer}>
<Text style={styles.title} numberOfLines={2}>
{currentItem.name}
</Text>
</View>
</TouchableOpacity>
)}
</Animated.View>
{/* Metadata Badge - Always Visible */}
<View style={styles.metadataContainer}>
@ -1020,21 +1231,33 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
</View>
</View>
{/* Action Buttons - Always Visible */}
{/* Action Buttons - Play and Save buttons */}
<View style={styles.buttonsContainer}>
{/* Info Button */}
{/* Play Button */}
<TouchableOpacity
style={styles.playButton}
onPress={() => {
navigation.navigate('Metadata', {
id: currentItem.id,
type: currentItem.type,
});
}}
activeOpacity={0.8}
style={[styles.playButton]}
onPress={handlePlayAction}
activeOpacity={0.85}
>
<MaterialIcons name="info-outline" size={28} color="#000" />
<Text style={styles.playButtonText}>Info</Text>
<MaterialIcons
name={playButtonText === 'Resume' ? "replay" : "play-arrow"}
size={24}
color="#000"
/>
<Text style={styles.playButtonText}>{playButtonText}</Text>
</TouchableOpacity>
{/* Save Button */}
<TouchableOpacity
style={styles.saveButton}
onPress={handleSaveAction}
activeOpacity={0.85}
>
<MaterialIcons
name={inLibrary ? "bookmark" : "bookmark-outline"}
size={24}
color="white"
/>
</TouchableOpacity>
</View>
@ -1171,25 +1394,25 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#fff',
paddingVertical: 14,
paddingVertical: 11,
paddingHorizontal: 32,
borderRadius: 24,
borderRadius: 40,
gap: 8,
minWidth: 140,
minWidth: 130,
},
playButtonText: {
color: '#000',
fontSize: 18,
fontWeight: '700',
},
secondaryButton: {
width: 48,
height: 48,
borderRadius: 24,
saveButton: {
width: 52,
height: 52,
borderRadius: 30,
backgroundColor: 'rgba(255,255,255,0.2)',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderWidth: 1.5,
borderColor: 'rgba(255,255,255,0.3)',
},
paginationContainer: {